mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-08 18:12:40 +08:00
Compare commits
261 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
204719caf8 | ||
|
|
267f53942b | ||
|
|
7ae9bbc4f0 | ||
|
|
717b460246 | ||
|
|
d52a814d77 | ||
|
|
6e1503334e | ||
|
|
34a33f87b2 | ||
|
|
03d885d391 | ||
|
|
c50b25997d | ||
|
|
3adcc894b7 | ||
|
|
8a73ad63ee | ||
|
|
69b5b2b900 | ||
|
|
97075fc167 | ||
|
|
bde70a2e26 | ||
|
|
2e05c8079b | ||
|
|
357191afcf | ||
|
|
8352ba335b | ||
|
|
7ae3e402cf | ||
|
|
c5e62cc8e4 | ||
|
|
ddb0befa4d | ||
|
|
82c19f3512 | ||
|
|
e95552b47a | ||
|
|
50ffcc6e92 | ||
|
|
43beb1df95 | ||
|
|
3cef928b75 | ||
|
|
6f5d62f1f9 | ||
|
|
e3a385c989 | ||
|
|
12356abf00 | ||
|
|
50e76496a2 | ||
|
|
a98bf08b2d | ||
|
|
697fd57bc7 | ||
|
|
7a691fe4e7 | ||
|
|
3822ab20d5 | ||
|
|
88f261584f | ||
|
|
62db4508da | ||
|
|
122acc7ad3 | ||
|
|
c15927cca0 | ||
|
|
b5b2de30a2 | ||
|
|
aebce53450 | ||
|
|
3c261a2c29 | ||
|
|
6a6a3bd463 | ||
|
|
ae62847ded | ||
|
|
c873787a89 | ||
|
|
410ff78ef5 | ||
|
|
ed53fbae93 | ||
|
|
a3d8aa6a33 | ||
|
|
24d03431c4 | ||
|
|
1d40d4a329 | ||
|
|
564896d99d | ||
|
|
2b2e25202d | ||
|
|
9055b95d00 | ||
|
|
5a8eb5b10e | ||
|
|
3e36cb6e31 | ||
|
|
6b4b44aec6 | ||
|
|
91a10c9d28 | ||
|
|
d7fbbd2d28 | ||
|
|
7b171e2c6f | ||
|
|
90ecaa1891 | ||
|
|
842f7401a0 | ||
|
|
77a6c591ff | ||
|
|
9bd3aebd73 | ||
|
|
b70d03e86b | ||
|
|
7d1ff9876f | ||
|
|
2cd8303191 | ||
|
|
21dbaf6db5 | ||
|
|
f9f45d9e32 | ||
|
|
ef5db9ee4b | ||
|
|
a909cdc21c | ||
|
|
b8e546a584 | ||
|
|
c4f54dcddc | ||
|
|
59b5e4a330 | ||
|
|
f8f7275438 | ||
|
|
6eec2e97f9 | ||
|
|
9020494f65 | ||
|
|
43fbc7abd7 | ||
|
|
d65a4b747d | ||
|
|
849fad8a8a | ||
|
|
f0b2d14502 | ||
|
|
fa169fb785 | ||
|
|
49acf7fba3 | ||
|
|
80d55dae8d | ||
|
|
76aa5407a2 | ||
|
|
d70789934f | ||
|
|
398e8b6afc | ||
|
|
593fede47c | ||
|
|
40c7e9c126 | ||
|
|
54e2f70ee0 | ||
|
|
81f85b9e46 | ||
|
|
60a5476e59 | ||
|
|
4271b63530 | ||
|
|
8aca17f0c6 | ||
|
|
4f238dc1a3 | ||
|
|
d4777fde70 | ||
|
|
b6c823c386 | ||
|
|
b7488214fc | ||
|
|
06b6c3f3cb | ||
|
|
abfaf926c4 | ||
|
|
6eabeb09c9 | ||
|
|
a15afabfa7 | ||
|
|
30276d5022 | ||
|
|
683ddc3fce | ||
|
|
f00f79279b | ||
|
|
7989965b1a | ||
|
|
5b84ce307b | ||
|
|
d13264b10e | ||
|
|
29a1c4ae35 | ||
|
|
9ac15e530a | ||
|
|
d4b446280a | ||
|
|
4593898549 | ||
|
|
c030d1a309 | ||
|
|
fd71e471b2 | ||
|
|
bc245e0a7a | ||
|
|
8236461c37 | ||
|
|
e1e8344764 | ||
|
|
14398e083e | ||
|
|
f36fe075ce | ||
|
|
25cf9d7fce | ||
|
|
9355788221 | ||
|
|
64042b51e9 | ||
|
|
7145af48ad | ||
|
|
ddb5468656 | ||
|
|
793cdd8f4c | ||
|
|
faafbb59c6 | ||
|
|
cd0ea07c2f | ||
|
|
f6e3807a3d | ||
|
|
fc36496aee | ||
|
|
1c8881d7a4 | ||
|
|
f6e8aacd0f | ||
|
|
79ddc39492 | ||
|
|
e63c5fb8e5 | ||
|
|
695f4827fd | ||
|
|
5a8b183c0f | ||
|
|
2845a889ed | ||
|
|
6333103050 | ||
|
|
cb6be91538 | ||
|
|
8cdd4b4af5 | ||
|
|
f4ec2029d9 | ||
|
|
b84b0f229f | ||
|
|
ef6a01a32f | ||
|
|
b451b8066a | ||
|
|
57efd516c5 | ||
|
|
d5979e6bf3 | ||
|
|
d75970cb2a | ||
|
|
ad4bb07cd7 | ||
|
|
9c558c3625 | ||
|
|
b467bb6c56 | ||
|
|
5cd021ea85 | ||
|
|
3d64382c9b | ||
|
|
6d5d4354d9 | ||
|
|
1b43446b5c | ||
|
|
7a9984f392 | ||
|
|
3c6fbfb106 | ||
|
|
bab46964ff | ||
|
|
661919f27a | ||
|
|
f3a03349b4 | ||
|
|
29791bf986 | ||
|
|
a06f0f29c6 | ||
|
|
b426d94180 | ||
|
|
5618d87e58 | ||
|
|
721d4f7685 | ||
|
|
7a025bcd38 | ||
|
|
24a8125621 | ||
|
|
468584a906 | ||
|
|
c056ec9377 | ||
|
|
87239994ae | ||
|
|
da09860a53 | ||
|
|
195ee5b2a6 | ||
|
|
32621ee299 | ||
|
|
40645180a0 | ||
|
|
59d4b1e544 | ||
|
|
8962a2c4ac | ||
|
|
6955f35ad1 | ||
|
|
1f722e7d7f | ||
|
|
5e587dfd88 | ||
|
|
2c687e5648 | ||
|
|
fdb0f63283 | ||
|
|
002e675b47 | ||
|
|
114f2a2dd0 | ||
|
|
c314d49e11 | ||
|
|
f5d0556808 | ||
|
|
27bc2a488f | ||
|
|
3a5999c341 | ||
|
|
a80a099ee7 | ||
|
|
68f458738a | ||
|
|
0f08f69738 | ||
|
|
a664066465 | ||
|
|
e6c11665a5 | ||
|
|
c119384c22 | ||
|
|
787cccb89f | ||
|
|
3df5d75c46 | ||
|
|
de6ad2479e | ||
|
|
632dfbaf10 | ||
|
|
68c14c24b8 | ||
|
|
d343d6d54d | ||
|
|
391a160f97 | ||
|
|
2d95110f75 | ||
|
|
e2ced8d36d | ||
|
|
a2b4511602 | ||
|
|
bdccc71b64 | ||
|
|
d7038a7d18 | ||
|
|
3998e1f685 | ||
|
|
5def9d5f81 | ||
|
|
c62937371e | ||
|
|
52843dcf97 | ||
|
|
ef5680d5ad | ||
|
|
bd3f24c84b | ||
|
|
399f85c52e | ||
|
|
14430e5c89 | ||
|
|
b703757d28 | ||
|
|
b642eabbb3 | ||
|
|
673596d8f9 | ||
|
|
b14e927e6c | ||
|
|
b03ae41ac7 | ||
|
|
92a0a9fe2f | ||
|
|
2511acfea1 | ||
|
|
361a4e0414 | ||
|
|
7e310236fe | ||
|
|
8705606c70 | ||
|
|
1f812a5258 | ||
|
|
e9264fa472 | ||
|
|
9164a1aefc | ||
|
|
30351a02ee | ||
|
|
7f918408a6 | ||
|
|
82f69bcad0 | ||
|
|
83b25eabbb | ||
|
|
47da6db51a | ||
|
|
eee092a7fd | ||
|
|
4c0f65fcbc | ||
|
|
acbd979569 | ||
|
|
52b68c18bf | ||
|
|
c6a74a75da | ||
|
|
e39eb62f52 | ||
|
|
4ecec4865d | ||
|
|
589007a22a | ||
|
|
4d4c9516c6 | ||
|
|
8491f26617 | ||
|
|
fcb3768a76 | ||
|
|
966bb769df | ||
|
|
dc8f7caab0 | ||
|
|
683346d652 | ||
|
|
f5fe39b2d2 | ||
|
|
51beb53f51 | ||
|
|
9d3f03c83a | ||
|
|
3eda1e4ef7 | ||
|
|
7181f83d66 | ||
|
|
fffad6e1b8 | ||
|
|
7f3906e5cb | ||
|
|
f836d175f0 | ||
|
|
f49cafc0cc | ||
|
|
a3ecad3436 | ||
|
|
a019dbd44e | ||
|
|
b316f960a1 | ||
|
|
d049b26825 | ||
|
|
852579c6ee | ||
|
|
5adcfa1877 | ||
|
|
f74458629e | ||
|
|
798f9249f8 | ||
|
|
6b4383643f | ||
|
|
256e8d0452 | ||
|
|
4112214c1f | ||
|
|
c183158ffe |
40
auto-imports.d.ts
vendored
40
auto-imports.d.ts
vendored
@@ -7,6 +7,7 @@
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
||||
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
|
||||
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
|
||||
const computed: typeof import('vue')['computed']
|
||||
@@ -21,13 +22,11 @@ declare global {
|
||||
const createGenericProjection: typeof import('@vueuse/math')['createGenericProjection']
|
||||
const createGlobalState: typeof import('@vueuse/core')['createGlobalState']
|
||||
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
|
||||
const createLogger: typeof import('vuex')['createLogger']
|
||||
const createNamespacedHelpers: typeof import('vuex')['createNamespacedHelpers']
|
||||
const createPinia: typeof import('pinia')['createPinia']
|
||||
const createProjection: typeof import('@vueuse/math')['createProjection']
|
||||
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
|
||||
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
|
||||
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
|
||||
const createStore: typeof import('vuex')['createStore']
|
||||
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
|
||||
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
|
||||
const customRef: typeof import('vue')['customRef']
|
||||
@@ -35,9 +34,11 @@ declare global {
|
||||
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
|
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const defineStore: typeof import('pinia')['defineStore']
|
||||
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const extendRef: typeof import('@vueuse/core')['extendRef']
|
||||
const getActivePinia: typeof import('pinia')['getActivePinia']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||
const h: typeof import('vue')['h']
|
||||
@@ -53,10 +54,11 @@ declare global {
|
||||
const logicNot: typeof import('@vueuse/math')['logicNot']
|
||||
const logicOr: typeof import('@vueuse/math')['logicOr']
|
||||
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
|
||||
const mapActions: typeof import('vuex')['mapActions']
|
||||
const mapGetters: typeof import('vuex')['mapGetters']
|
||||
const mapMutations: typeof import('vuex')['mapMutations']
|
||||
const mapState: typeof import('vuex')['mapState']
|
||||
const mapActions: typeof import('pinia')['mapActions']
|
||||
const mapGetters: typeof import('pinia')['mapGetters']
|
||||
const mapState: typeof import('pinia')['mapState']
|
||||
const mapStores: typeof import('pinia')['mapStores']
|
||||
const mapWritableState: typeof import('pinia')['mapWritableState']
|
||||
const markRaw: typeof import('vue')['markRaw']
|
||||
const nextTick: typeof import('vue')['nextTick']
|
||||
const onActivated: typeof import('vue')['onActivated']
|
||||
@@ -99,9 +101,12 @@ declare global {
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const resolveRef: typeof import('@vueuse/core')['resolveRef']
|
||||
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
|
||||
const setActivePinia: typeof import('pinia')['setActivePinia']
|
||||
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const storeToRefs: typeof import('pinia')['storeToRefs']
|
||||
const syncRef: typeof import('@vueuse/core')['syncRef']
|
||||
const syncRefs: typeof import('@vueuse/core')['syncRefs']
|
||||
const templateRef: typeof import('@vueuse/core')['templateRef']
|
||||
@@ -263,7 +268,6 @@ declare global {
|
||||
const useStepper: typeof import('@vueuse/core')['useStepper']
|
||||
const useStorage: typeof import('@vueuse/core')['useStorage']
|
||||
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
|
||||
const useStore: typeof import('vuex')['useStore']
|
||||
const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
|
||||
const useSum: typeof import('@vueuse/math')['useSum']
|
||||
const useSupported: typeof import('@vueuse/core')['useSupported']
|
||||
@@ -331,6 +335,7 @@ declare module 'vue' {
|
||||
interface GlobalComponents {}
|
||||
interface ComponentCustomProperties {
|
||||
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
|
||||
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
|
||||
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
|
||||
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
|
||||
readonly computed: UnwrapRef<typeof import('vue')['computed']>
|
||||
@@ -345,13 +350,11 @@ declare module 'vue' {
|
||||
readonly createGenericProjection: UnwrapRef<typeof import('@vueuse/math')['createGenericProjection']>
|
||||
readonly createGlobalState: UnwrapRef<typeof import('@vueuse/core')['createGlobalState']>
|
||||
readonly createInjectionState: UnwrapRef<typeof import('@vueuse/core')['createInjectionState']>
|
||||
readonly createLogger: UnwrapRef<typeof import('vuex')['createLogger']>
|
||||
readonly createNamespacedHelpers: UnwrapRef<typeof import('vuex')['createNamespacedHelpers']>
|
||||
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
|
||||
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']>
|
||||
@@ -359,9 +362,11 @@ declare module 'vue' {
|
||||
readonly debouncedWatch: UnwrapRef<typeof import('@vueuse/core')['debouncedWatch']>
|
||||
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
|
||||
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
|
||||
readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
|
||||
readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']>
|
||||
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
|
||||
readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
|
||||
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
|
||||
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
|
||||
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
||||
readonly h: UnwrapRef<typeof import('vue')['h']>
|
||||
@@ -377,10 +382,11 @@ declare module 'vue' {
|
||||
readonly logicNot: UnwrapRef<typeof import('@vueuse/math')['logicNot']>
|
||||
readonly logicOr: UnwrapRef<typeof import('@vueuse/math')['logicOr']>
|
||||
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
|
||||
readonly mapActions: UnwrapRef<typeof import('vuex')['mapActions']>
|
||||
readonly mapGetters: UnwrapRef<typeof import('vuex')['mapGetters']>
|
||||
readonly mapMutations: UnwrapRef<typeof import('vuex')['mapMutations']>
|
||||
readonly mapState: UnwrapRef<typeof import('vuex')['mapState']>
|
||||
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
|
||||
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
|
||||
readonly mapState: UnwrapRef<typeof import('pinia')['mapState']>
|
||||
readonly mapStores: UnwrapRef<typeof import('pinia')['mapStores']>
|
||||
readonly mapWritableState: UnwrapRef<typeof import('pinia')['mapWritableState']>
|
||||
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
|
||||
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
|
||||
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
|
||||
@@ -423,9 +429,12 @@ declare module 'vue' {
|
||||
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
|
||||
readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']>
|
||||
readonly resolveUnref: UnwrapRef<typeof import('@vueuse/core')['resolveUnref']>
|
||||
readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>
|
||||
readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']>
|
||||
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
|
||||
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
|
||||
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
|
||||
readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']>
|
||||
readonly syncRef: UnwrapRef<typeof import('@vueuse/core')['syncRef']>
|
||||
readonly syncRefs: UnwrapRef<typeof import('@vueuse/core')['syncRefs']>
|
||||
readonly templateRef: UnwrapRef<typeof import('@vueuse/core')['templateRef']>
|
||||
@@ -587,7 +596,6 @@ declare module 'vue' {
|
||||
readonly useStepper: UnwrapRef<typeof import('@vueuse/core')['useStepper']>
|
||||
readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']>
|
||||
readonly useStorageAsync: UnwrapRef<typeof import('@vueuse/core')['useStorageAsync']>
|
||||
readonly useStore: UnwrapRef<typeof import('vuex')['useStore']>
|
||||
readonly useStyleTag: UnwrapRef<typeof import('@vueuse/core')['useStyleTag']>
|
||||
readonly useSum: UnwrapRef<typeof import('@vueuse/math')['useSum']>
|
||||
readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']>
|
||||
|
||||
362
index.html
362
index.html
@@ -1,161 +1,221 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html
|
||||
lang="en"
|
||||
style="
|
||||
overflow: hidden auto;
|
||||
min-block-size: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));
|
||||
background: var(--initial-loader-bg, #fff);
|
||||
"
|
||||
>
|
||||
<head>
|
||||
<meta http-equiv="pragma" content="no-cache" />
|
||||
<meta http-equiv="cache-control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="expires" content="0" />
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="initial-scale=1, viewport-fit=cover, width=device-width, user-scalable=no" />
|
||||
<title>MoviePilot</title>
|
||||
<meta name="Robots" content="noindex,nofollow,noarchive" />
|
||||
<meta name="referrer" content="origin" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/apple-splash.jpg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="MoviePilot" />
|
||||
<meta name="description" content="MoviePilot" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="referrer" content="never" />
|
||||
<meta name="msapplication-TileColor" content="#7D34FD" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#0E1116" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#F4F5FA" media="(prefers-color-scheme: light)" />
|
||||
<meta name="HandheldFriendly" content="True" />
|
||||
<meta name="MobileOptimized" content="320" />
|
||||
<link rel="stylesheet" type="text/css" href="/loader.css" />
|
||||
<script>
|
||||
const loaderColor = localStorage.getItem('materio-initial-loader-bg') || '#FFFFFF'
|
||||
if (loaderColor) document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
|
||||
const primaryColor = localStorage.getItem('materio-initial-loader-color') || '#9155FD'
|
||||
if (primaryColor) document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<head>
|
||||
<meta http-equiv="pragma" content="no-cache">
|
||||
<meta http-equiv="cache-control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="expires" content="0">
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="initial-scale=1, viewport-fit=cover, width=device-width, user-scalable=no" />
|
||||
<title>MoviePilot</title>
|
||||
<meta name="Robots" content="noindex,nofollow,noarchive" />
|
||||
<meta name="referrer" content="origin" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/apple-splash.jpg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="MoviePilot" />
|
||||
<meta name="description" content="MoviePilot" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="referrer" content="never" />
|
||||
<meta name="msapplication-TileColor" content="#7D34FD" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#28243D" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#F4F5FA" media="(prefers-color-scheme: light)" />
|
||||
<meta name="HandheldFriendly" content="True" />
|
||||
<meta name="MobileOptimized" content="320" />
|
||||
<link rel="stylesheet" type="text/css" href="/loader.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="loading-bg">
|
||||
<div class="loading-logo">
|
||||
<!-- Logo -->
|
||||
<svg width="10rem" height="10rem" viewBox="0 0 192 192" version="1.1" xmlns="http://www.w3.org/2000/svg"
|
||||
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2">
|
||||
<g transform="matrix(1,0,0,1,-2606,-236)">
|
||||
<g id="a2-c" transform="matrix(1,0,0,1,2606,236)">
|
||||
<rect x="0" y="0" width="192" height="192" style="fill: none" />
|
||||
<g transform="matrix(-0.800798,0.462341,-0.769972,-1.33363,1869.11,-896.718)">
|
||||
<path
|
||||
d="M2241.27,-28.175C2238.86,-28.931 2236.64,-29.181 2234.48,-29.254L2159.78,-29.286L2165.01,-11.207C2167.16,-13.121 2169.64,-13.722 2172.26,-13.808L2222.12,-13.822C2223.52,-13.824 2225,-13.701 2226.78,-13.108L2241.27,-28.175Z"
|
||||
style="fill: url(#_Linear1)" />
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2205.67,331.428L2205.67,332.25L2205.67,352.835C2205.67,354.263 2204.91,355.583 2203.67,356.298C2202.43,357.012 2200.91,357.013 2199.67,356.3L2190.78,351.174C2189.73,350.595 2188.83,350.083 2188.03,349.59L2187.45,349.257C2186.66,348.725 2185.91,348.142 2185.21,347.461C2185.08,347.331 2184.95,347.198 2184.82,347.061C2184.26,346.457 2183.75,345.778 2183.3,344.995C2182.16,343.05 2181.69,341.024 2181.68,338.948L2181.67,268.923L2209.77,274.425C2207.5,275.639 2205.68,278.3 2205.67,281.429L2205.67,331.428Z"
|
||||
style="fill: url(#_Linear2)" />
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2295.93,363.064C2295.73,363.184 2295.53,363.301 2295.32,363.414L2295.93,363.064Z"
|
||||
style="fill: rgb(141, 81, 249)" />
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2299.79,360.238C2299.79,360.238 2320.03,348.464 2320.04,348.461C2323.1,346.372 2324.69,343.444 2325.17,339.877C2325.17,339.877 2325.17,269.846 2325.17,269.839C2325.06,267.482 2324.56,265.739 2323.61,264.133C2322.56,262.445 2321.26,261.005 2319.55,259.97L2304.42,251.217C2303.96,250.949 2303.39,250.948 2302.92,251.216C2302.46,251.484 2302.17,251.979 2302.17,252.515L2302.17,276.775L2302.17,277.879L2302.17,352.926C2302.17,352.933 2302.17,352.941 2302.17,352.948C2302.04,355.861 2301.23,358.279 2299.79,360.238Z"
|
||||
style="fill: url(#_Linear3)" />
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256Z"
|
||||
style="fill: rgb(165, 118, 255)" />
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256ZM2253.68,223.756C2251.6,223.789 2249.87,224.269 2248.47,224.996L2188.17,259.754C2184.35,261.992 2182.35,265.367 2182.18,269.874C2182.18,269.874 2182.17,292.759 2182.17,292.757C2183.25,290.047 2185.13,288.051 2187.62,286.607L2249.57,250.919C2249.58,250.917 2249.58,250.915 2249.59,250.913C2250.83,250.243 2252.17,249.839 2253.67,249.847C2255.21,249.841 2256.54,250.253 2257.76,250.914C2257.76,250.916 2257.76,250.917 2257.76,250.919L2274.92,260.807C2275.38,261.075 2275.95,261.074 2276.42,260.806C2276.88,260.538 2277.17,260.043 2277.17,259.508L2277.17,237.568C2277.17,236.317 2276.5,235.16 2275.42,234.535C2275.42,234.535 2258.88,225 2258.87,224.996C2256.87,224.049 2255.2,223.746 2253.68,223.756Z"
|
||||
style="fill: url(#_Linear4)" />
|
||||
</g>
|
||||
<g transform="matrix(0.800798,0.462341,0.769972,-1.33363,-1677.22,-896.858)">
|
||||
<path
|
||||
d="M2241.55,-28.184C2239.1,-28.989 2236.83,-29.204 2234.68,-29.295C2234.68,-29.295 2220.82,-29.3 2215.03,-29.303C2213.48,-29.303 2212.05,-28.808 2211.28,-28.004C2208.65,-25.275 2202.56,-18.936 2199.45,-15.709C2199.07,-15.306 2199.07,-14.809 2199.46,-14.406C2199.85,-14.004 2200.57,-13.758 2201.34,-13.761C2208.36,-13.788 2222.72,-13.845 2222.72,-13.845C2223.98,-13.851 2225.44,-13.657 2227.06,-13.117L2241.55,-28.184Z"
|
||||
style="fill: rgb(141, 81, 249)" />
|
||||
</g>
|
||||
<g transform="matrix(-4.32309,0,0,12.4454,9610.35,-1450.35)">
|
||||
<path
|
||||
d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z"
|
||||
style="fill: rgb(104, 0, 197)" />
|
||||
<clipPath id="_clip5">
|
||||
<body style="margin: 0">
|
||||
<div id="loading-bg">
|
||||
<div class="loading-logo">
|
||||
<!-- Logo -->
|
||||
<svg
|
||||
width="10rem"
|
||||
height="10rem"
|
||||
viewBox="0 0 192 192"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
|
||||
>
|
||||
<g transform="matrix(1,0,0,1,-2606,-236)">
|
||||
<g id="a2-c" transform="matrix(1,0,0,1,2606,236)">
|
||||
<rect x="0" y="0" width="192" height="192" style="fill: none" />
|
||||
<g transform="matrix(-0.800798,0.462341,-0.769972,-1.33363,1869.11,-896.718)">
|
||||
<path
|
||||
d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z" />
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip5)">
|
||||
<g transform="matrix(0.124502,0.074907,0.206623,-0.0414384,1997.62,-7.40235)">
|
||||
d="M2241.27,-28.175C2238.86,-28.931 2236.64,-29.181 2234.48,-29.254L2159.78,-29.286L2165.01,-11.207C2167.16,-13.121 2169.64,-13.722 2172.26,-13.808L2222.12,-13.822C2223.52,-13.824 2225,-13.701 2226.78,-13.108L2241.27,-28.175Z"
|
||||
style="fill: url(#_Linear1)"
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2205.67,331.428L2205.67,332.25L2205.67,352.835C2205.67,354.263 2204.91,355.583 2203.67,356.298C2202.43,357.012 2200.91,357.013 2199.67,356.3L2190.78,351.174C2189.73,350.595 2188.83,350.083 2188.03,349.59L2187.45,349.257C2186.66,348.725 2185.91,348.142 2185.21,347.461C2185.08,347.331 2184.95,347.198 2184.82,347.061C2184.26,346.457 2183.75,345.778 2183.3,344.995C2182.16,343.05 2181.69,341.024 2181.68,338.948L2181.67,268.923L2209.77,274.425C2207.5,275.639 2205.68,278.3 2205.67,281.429L2205.67,331.428Z"
|
||||
style="fill: url(#_Linear2)"
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2295.93,363.064C2295.73,363.184 2295.53,363.301 2295.32,363.414L2295.93,363.064Z"
|
||||
style="fill: rgb(141, 81, 249)"
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2299.79,360.238C2299.79,360.238 2320.03,348.464 2320.04,348.461C2323.1,346.372 2324.69,343.444 2325.17,339.877C2325.17,339.877 2325.17,269.846 2325.17,269.839C2325.06,267.482 2324.56,265.739 2323.61,264.133C2322.56,262.445 2321.26,261.005 2319.55,259.97L2304.42,251.217C2303.96,250.949 2303.39,250.948 2302.92,251.216C2302.46,251.484 2302.17,251.979 2302.17,252.515L2302.17,276.775L2302.17,277.879L2302.17,352.926C2302.17,352.933 2302.17,352.941 2302.17,352.948C2302.04,355.861 2301.23,358.279 2299.79,360.238Z"
|
||||
style="fill: url(#_Linear3)"
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256Z"
|
||||
style="fill: rgb(165, 118, 255)"
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
|
||||
<path
|
||||
d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256ZM2253.68,223.756C2251.6,223.789 2249.87,224.269 2248.47,224.996L2188.17,259.754C2184.35,261.992 2182.35,265.367 2182.18,269.874C2182.18,269.874 2182.17,292.759 2182.17,292.757C2183.25,290.047 2185.13,288.051 2187.62,286.607L2249.57,250.919C2249.58,250.917 2249.58,250.915 2249.59,250.913C2250.83,250.243 2252.17,249.839 2253.67,249.847C2255.21,249.841 2256.54,250.253 2257.76,250.914C2257.76,250.916 2257.76,250.917 2257.76,250.919L2274.92,260.807C2275.38,261.075 2275.95,261.074 2276.42,260.806C2276.88,260.538 2277.17,260.043 2277.17,259.508L2277.17,237.568C2277.17,236.317 2276.5,235.16 2275.42,234.535C2275.42,234.535 2258.88,225 2258.87,224.996C2256.87,224.049 2255.2,223.746 2253.68,223.756Z"
|
||||
style="fill: url(#_Linear4)"
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(0.800798,0.462341,0.769972,-1.33363,-1677.22,-896.858)">
|
||||
<path
|
||||
d="M2241.55,-28.184C2239.1,-28.989 2236.83,-29.204 2234.68,-29.295C2234.68,-29.295 2220.82,-29.3 2215.03,-29.303C2213.48,-29.303 2212.05,-28.808 2211.28,-28.004C2208.65,-25.275 2202.56,-18.936 2199.45,-15.709C2199.07,-15.306 2199.07,-14.809 2199.46,-14.406C2199.85,-14.004 2200.57,-13.758 2201.34,-13.761C2208.36,-13.788 2222.72,-13.845 2222.72,-13.845C2223.98,-13.851 2225.44,-13.657 2227.06,-13.117L2241.55,-28.184Z"
|
||||
style="fill: rgb(141, 81, 249)"
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(-4.32309,0,0,12.4454,9610.35,-1450.35)">
|
||||
<path
|
||||
d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z"
|
||||
style="fill: rgb(104, 0, 197)"
|
||||
/>
|
||||
<clipPath id="_clip5">
|
||||
<path
|
||||
d="M1726.17,-64.249L1708.16,-72.303L1708.05,-23.514L1721.88,-32.386C1722.96,-33.241 1723.09,-33.944 1723.15,-34.636L1723.15,-54.373C1723.19,-56.238 1724.96,-57.594 1726.87,-56.686L1726.17,-64.249Z"
|
||||
style="fill: url(#_Linear6)" />
|
||||
</g>
|
||||
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
|
||||
<path
|
||||
d="M1726.17,-45.661L1704.47,-40.254C1706.28,-40.527 1708.14,-40.212 1708.16,-39.416L1708.16,-18.976L1726.17,-18.976L1726.17,-45.661Z"
|
||||
style="fill: rgb(141, 81, 249)" />
|
||||
</g>
|
||||
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
|
||||
<path
|
||||
d="M1726.17,-45.661L1726.17,-18.976L1708.16,-18.976L1708.16,-39.416C1707.79,-40.732 1704.5,-40.298 1702.68,-40.025L1726.17,-45.661ZM1705.49,-40.491C1706.2,-40.507 1706.87,-40.464 1707.4,-40.327C1708.01,-40.173 1708.48,-39.899 1708.62,-39.436C1708.62,-39.429 1708.62,-39.423 1708.62,-39.416L1708.62,-19.152C1708.62,-19.152 1725.72,-19.152 1725.72,-19.152L1725.72,-45.345L1705.49,-40.491Z"
|
||||
style="fill: url(#_Radial7)" />
|
||||
d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z"
|
||||
/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip5)">
|
||||
<g transform="matrix(0.124502,0.074907,0.206623,-0.0414384,1997.62,-7.40235)">
|
||||
<path
|
||||
d="M1726.17,-64.249L1708.16,-72.303L1708.05,-23.514L1721.88,-32.386C1722.96,-33.241 1723.09,-33.944 1723.15,-34.636L1723.15,-54.373C1723.19,-56.238 1724.96,-57.594 1726.87,-56.686L1726.17,-64.249Z"
|
||||
style="fill: url(#_Linear6)"
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
|
||||
<path
|
||||
d="M1726.17,-45.661L1704.47,-40.254C1706.28,-40.527 1708.14,-40.212 1708.16,-39.416L1708.16,-18.976L1726.17,-18.976L1726.17,-45.661Z"
|
||||
style="fill: rgb(141, 81, 249)"
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
|
||||
<path
|
||||
d="M1726.17,-45.661L1726.17,-18.976L1708.16,-18.976L1708.16,-39.416C1707.79,-40.732 1704.5,-40.298 1702.68,-40.025L1726.17,-45.661ZM1705.49,-40.491C1706.2,-40.507 1706.87,-40.464 1707.4,-40.327C1708.01,-40.173 1708.48,-39.899 1708.62,-39.436C1708.62,-39.429 1708.62,-39.423 1708.62,-39.416L1708.62,-19.152C1708.62,-19.152 1725.72,-19.152 1725.72,-19.152L1725.72,-45.345L1705.49,-40.491Z"
|
||||
style="fill: url(#_Radial7)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(-70.0711,-0.927611,1.54482,-42.0752,2233.59,-20.1891)">
|
||||
<stop offset="0" style="stop-color: rgb(141, 81, 249); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="_Linear2" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(4.78193e-15,-78.0949,78.0949,4.78193e-15,2195.72,354.021)">
|
||||
<stop offset="0" style="stop-color: rgb(141, 81, 249); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(41.6089,41.5866,-41.5866,41.6089,2282.31,262.837)">
|
||||
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
|
||||
</linearGradient>
|
||||
<linearGradient id="_Linear4" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(9.25616,16.7005,-16.7005,9.25616,2215,243.712)">
|
||||
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
|
||||
</linearGradient>
|
||||
<linearGradient id="_Linear6" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(-0.130164,-61.9937,59.4003,-0.135847,1711.63,-25.7957)">
|
||||
<stop offset="0" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
|
||||
<stop offset="0.51" style="stop-color: rgb(110, 38, 217); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(91, 0, 197); stop-opacity: 1" />
|
||||
</linearGradient>
|
||||
<radialGradient id="_Radial7" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(13.8659,4.71436,-12.1609,5.37534,1708.16,-32.287)">
|
||||
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="_Linear1"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="1"
|
||||
y2="0"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(-70.0711,-0.927611,1.54482,-42.0752,2233.59,-20.1891)"
|
||||
>
|
||||
<stop offset="0" style="stop-color: rgb(141, 81, 249); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="_Linear2"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="1"
|
||||
y2="0"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(4.78193e-15,-78.0949,78.0949,4.78193e-15,2195.72,354.021)"
|
||||
>
|
||||
<stop offset="0" style="stop-color: rgb(141, 81, 249); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="_Linear3"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="1"
|
||||
y2="0"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(41.6089,41.5866,-41.5866,41.6089,2282.31,262.837)"
|
||||
>
|
||||
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="_Linear4"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="1"
|
||||
y2="0"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(9.25616,16.7005,-16.7005,9.25616,2215,243.712)"
|
||||
>
|
||||
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="_Linear6"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="1"
|
||||
y2="0"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(-0.130164,-61.9937,59.4003,-0.135847,1711.63,-25.7957)"
|
||||
>
|
||||
<stop offset="0" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
|
||||
<stop offset="0.51" style="stop-color: rgb(110, 38, 217); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(91, 0, 197); stop-opacity: 1" />
|
||||
</linearGradient>
|
||||
<radialGradient
|
||||
id="_Radial7"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(13.8659,4.71436,-12.1609,5.37534,1708.16,-32.287)"
|
||||
>
|
||||
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
|
||||
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="loading">
|
||||
<div class="effect-1 effects"></div>
|
||||
<div class="effect-2 effects"></div>
|
||||
<div class="effect-3 effects"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="loading">
|
||||
<div class="effect-1 effects"></div>
|
||||
<div class="effect-2 effects"></div>
|
||||
<div class="effect-3 effects"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<script>
|
||||
const loaderColor = localStorage.getItem('materio-initial-loader-bg') || '#FFFFFF'
|
||||
const primaryColor = localStorage.getItem('materio-initial-loader-color') || '#9155FD'
|
||||
|
||||
if (loaderColor)
|
||||
document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
|
||||
|
||||
if (primaryColor)
|
||||
document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
|
||||
</script>
|
||||
</body>
|
||||
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
14609
package-lock.json
generated
Normal file
14609
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.2.2",
|
||||
"version": "2.3.8",
|
||||
"private": true,
|
||||
"bin": "dist/service.js",
|
||||
"scripts": {
|
||||
@@ -26,6 +26,13 @@
|
||||
"@fullcalendar/timegrid": "^6.1.15",
|
||||
"@fullcalendar/vue3": "^6.1.15",
|
||||
"@iconify/utils": "^2.2.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@vue-flow/background": "^1.3.2",
|
||||
"@vue-flow/controls": "^1.1.2",
|
||||
"@vue-flow/core": "^1.42.1",
|
||||
"@vue-flow/minimap": "^1.5.2",
|
||||
"@vue-flow/node-resizer": "^1.4.0",
|
||||
"@vue-flow/node-toolbar": "^1.1.0",
|
||||
"@vue-js-cron/vuetify": "^5.0.9",
|
||||
"@vueuse/core": "^12.4.0",
|
||||
"@vueuse/math": "^12.4.0",
|
||||
@@ -37,9 +44,12 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"express": "^4.21.2",
|
||||
"express-http-proxy": "^2.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mousetrap": "^1.6.5",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^3.0.1",
|
||||
"pinia-plugin-persistedstate": "^4.2.0",
|
||||
"qrcode.vue": "^3.6.0",
|
||||
"sass": "^1.83.4",
|
||||
"tailwindcss": "^ 3.4.17",
|
||||
@@ -52,8 +62,6 @@
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuetify": "3.7.3",
|
||||
"vuetify-use-dialog": "^0.6.11",
|
||||
"vuex": "^4.1.0",
|
||||
"vuex-persistedstate": "^4.1.0",
|
||||
"webfontloader": "^1.6.28"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -62,8 +70,10 @@
|
||||
"@iconify/vue": "^4.3.0",
|
||||
"@intlify/unplugin-vue-i18n": "^6.0.3",
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@types/lodash": "^4.14.197",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^20.1.4",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/webfontloader": "^1.6.34",
|
||||
"@typescript-eslint/eslint-plugin": "^8.20.0",
|
||||
"@typescript-eslint/parser": "^8.20.0",
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow: hidden auto;
|
||||
background: var(--initial-loader-bg, #fff);
|
||||
min-block-size: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
#loading-bg {
|
||||
position: absolute;
|
||||
z-index: 999;
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
display: block;
|
||||
background: var(--initial-loader-bg, #fff);
|
||||
block-size: 100vh;
|
||||
@@ -82,4 +72,4 @@ html {
|
||||
opacity: 1;
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { ThemeSwitcherTheme } from '@layouts/types'
|
||||
import api from '@/api'
|
||||
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { saveLocalTheme } from '../utils/theme'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -17,10 +18,12 @@ const { name: themeName, global: globalTheme } = useTheme()
|
||||
|
||||
const savedTheme = ref(localStorage.getItem('theme') ?? themeName)
|
||||
|
||||
const { state: currentThemeName, next: getNextThemeName } = useCycleList(
|
||||
props.themes.map(t => t.name),
|
||||
{ initialValue: savedTheme.value },
|
||||
)
|
||||
const currentThemeName = ref(savedTheme.value)
|
||||
const getNextThemeName = () => {
|
||||
const currentIndex = props.themes.findIndex(t => t.name === currentThemeName.value)
|
||||
const nextIndex = (currentIndex + 1) % props.themes.length
|
||||
return props.themes[nextIndex].name
|
||||
}
|
||||
|
||||
const $toast = useToast()
|
||||
|
||||
@@ -102,8 +105,7 @@ function updateTheme() {
|
||||
savedTheme.value = theme
|
||||
themeTransition()
|
||||
// 保存主题到本地
|
||||
localStorage.setItem('theme', theme)
|
||||
localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background)
|
||||
saveLocalTheme(theme, globalTheme)
|
||||
}
|
||||
|
||||
// 切换主题
|
||||
@@ -187,29 +189,51 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VMenu v-if="props.themes">
|
||||
<VMenu v-if="props.themes" class="theme-menu">
|
||||
<template v-slot:activator="{ props }">
|
||||
<IconBtn v-bind="props">
|
||||
<VIcon :icon="getThemeIcon" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
<VList>
|
||||
<VListItem v-for="theme in props.themes" :key="theme.name" @click="changeTheme(theme.name)">
|
||||
<template #prepend>
|
||||
<VIcon :icon="theme.icon" />
|
||||
</template>
|
||||
<VListItemTitle>{{ theme.title }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem @click="cssDialog = true">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-palette" />
|
||||
</template>
|
||||
<VListItemTitle>自定义</VListItemTitle>
|
||||
</VListItem>
|
||||
<VList elevation="0" class="theme-switcher-list">
|
||||
<VCardItem class="theme-switcher-header">
|
||||
<VCardTitle class="font-weight-medium text-primary">主题选择</VCardTitle>
|
||||
</VCardItem>
|
||||
|
||||
<div class="theme-switcher-options px-2">
|
||||
<VListItem
|
||||
v-for="theme in props.themes"
|
||||
:key="theme.name"
|
||||
@click="changeTheme(theme.name)"
|
||||
class="theme-option"
|
||||
:class="{ 'theme-option-active': currentThemeName === theme.name }"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="theme-icon-wrapper">
|
||||
<VIcon :icon="theme.icon" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle>{{ theme.title }}</VListItemTitle>
|
||||
<template #append v-if="currentThemeName === theme.name">
|
||||
<VIcon icon="mdi-check" color="primary" size="small" />
|
||||
</template>
|
||||
</VListItem>
|
||||
|
||||
<VDivider class="my-2" />
|
||||
|
||||
<VListItem @click="cssDialog = true" class="theme-option custom-theme-option">
|
||||
<template #prepend>
|
||||
<div class="theme-icon-wrapper custom-theme-icon">
|
||||
<VIcon icon="mdi-palette" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle>自定义主题</VListItemTitle>
|
||||
</VListItem>
|
||||
</div>
|
||||
</VList>
|
||||
</VMenu>
|
||||
<!-- 自定义 CSS -- -->
|
||||
<VDialog v-model="cssDialog" persistent max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VDialog v-if="cssDialog" v-model="cssDialog" max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard title="自定义主题风格">
|
||||
<DialogCloseBtn @click="cssDialog = false" />
|
||||
<VDivider />
|
||||
@@ -232,18 +256,73 @@ onMounted(() => {
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style lang="sass">
|
||||
// Theme transition
|
||||
.app-copy
|
||||
position: fixed !important
|
||||
z-index: -1 !important
|
||||
pointer-events: none !important
|
||||
contain: size style !important
|
||||
overflow: clip !important
|
||||
<style lang="scss">
|
||||
.theme-switcher-header {
|
||||
background: linear-gradient(to right, rgba(var(--v-theme-primary), 0.04), rgba(var(--v-theme-primary), 0.01));
|
||||
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
padding-block: 12px;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.app-transition
|
||||
--clip-size: 0
|
||||
--clip-pos: 0 0
|
||||
clip-path: circle(var(--clip-size) at var(--clip-pos))
|
||||
transition: clip-path .35s ease-out
|
||||
.theme-switcher-options {
|
||||
max-block-size: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.theme-option {
|
||||
border-radius: 8px;
|
||||
margin-block: 4px;
|
||||
margin-inline: 0;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.04);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
&.theme-option-active {
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
block-size: 36px;
|
||||
inline-size: 36px;
|
||||
margin-inline-end: 12px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.v-icon {
|
||||
color: rgba(var(--v-theme-primary), 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.custom-theme-icon {
|
||||
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.15), rgba(var(--v-theme-info), 0.15));
|
||||
|
||||
.v-icon {
|
||||
color: rgba(var(--v-theme-primary), 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
// Theme transition
|
||||
.app-copy {
|
||||
position: fixed !important;
|
||||
z-index: -1 !important;
|
||||
overflow: clip !important;
|
||||
contain: size style !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.app-transition {
|
||||
--clip-size: 0;
|
||||
--clip-pos: 0 0;
|
||||
|
||||
clip-path: circle(var(--clip-size) at var(--clip-pos));
|
||||
transition: clip-path 0.35s ease-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
@extend %nav;
|
||||
|
||||
|
||||
@at-root {
|
||||
// ℹ️ Add styles for collapsed vertical nav
|
||||
.layout-vertical-nav-collapsed#{$sl-layout-nav-type-vertical}.hovered {
|
||||
@@ -60,9 +61,9 @@
|
||||
z-index: 1;
|
||||
background:
|
||||
linear-gradient(
|
||||
rgb(#{variables.$vertical-nav-background-color-rgb}) 5%,
|
||||
rgba(#{variables.$vertical-nav-background-color-rgb}, 75%) 45%,
|
||||
rgba(#{variables.$vertical-nav-background-color-rgb}, 20%) 80%,
|
||||
rgb(var(--v-theme-surface)) 5%,
|
||||
rgba(var(--v-theme-surface), 75%) 45%,
|
||||
rgba(var(--v-theme-surface), 20%) 80%,
|
||||
transparent
|
||||
);
|
||||
block-size: 55px;
|
||||
@@ -85,8 +86,8 @@
|
||||
}
|
||||
|
||||
.ps__rail-y {
|
||||
// ℹ️ Setting z-index: 1 will make perfect scrollbar thumb appear on top of vertical nav items shadow
|
||||
z-index: 1;
|
||||
// ℹ️ Setting z-index: 1 will make perfect scrollbar thumb appear on top of vertical nav items shadow;Settingz-indexSettingz-indexSettingz-indexSettingz-index
|
||||
z-index: 1z-indexz-indexz-index
|
||||
}
|
||||
|
||||
// 👉 Nav section title
|
||||
|
||||
@@ -212,10 +212,6 @@ h6,
|
||||
}
|
||||
}
|
||||
|
||||
.v-data-table-footer {
|
||||
margin-block-start: 1rem;
|
||||
}
|
||||
|
||||
// 👉 v-field
|
||||
.v-field:hover .v-field__outline {
|
||||
--v-field-border-opacity: var(--v-medium-emphasis-opacity);
|
||||
|
||||
@@ -176,10 +176,6 @@
|
||||
th {
|
||||
background: rgb(var(--v-table-header-background)) !important;
|
||||
}
|
||||
|
||||
.v-data-table-footer {
|
||||
margin-block-start: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Pagination
|
||||
|
||||
@@ -10,8 +10,7 @@ export function useDefer(maxFrameCount = 1) {
|
||||
const refreshFrameCount = () => {
|
||||
requestAnimationFrame(() => {
|
||||
frameCount.value++
|
||||
if (frameCount.value < maxFrameCount)
|
||||
refreshFrameCount()
|
||||
if (frameCount.value < maxFrameCount) refreshFrameCount()
|
||||
})
|
||||
}
|
||||
refreshFrameCount()
|
||||
@@ -19,3 +18,9 @@ export function useDefer(maxFrameCount = 1) {
|
||||
return frameCount.value >= showInFrameCount
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureRenderComplete(callback: () => void) {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(callback)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -153,3 +153,12 @@ export function formatDateDifference(dateString: string): string {
|
||||
if (!dateString) return ''
|
||||
return dayjs(dateString).fromNow()
|
||||
}
|
||||
|
||||
// 格式化评份,如为10及以下的数按原值显示,否则格式化为xxM、xxK显示
|
||||
export function formatRating(rating: number): string {
|
||||
if (!rating) return ''
|
||||
if (rating <= 10) return rating.toString()
|
||||
if (rating < 1000) return rating.toLocaleString()
|
||||
if (rating < 1000 * 1000) return `${(rating / 1000).toFixed(1)}K`
|
||||
return `${(rating / 1000 / 1000).toFixed(1)}M`
|
||||
}
|
||||
|
||||
6
src/@core/utils/theme.ts
Normal file
6
src/@core/utils/theme.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function saveLocalTheme(name: string, theme: any) {
|
||||
// 存储主题到本地
|
||||
localStorage.setItem('theme', name)
|
||||
localStorage.setItem('materio-initial-loader-bg', theme.current.value.colors.background)
|
||||
localStorage.setItem('materio-initial-loader-color', theme.current.value.colors.primary)
|
||||
}
|
||||
122
src/@core/utils/workflow.ts
Normal file
122
src/@core/utils/workflow.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useVueFlow } from '@vue-flow/core'
|
||||
import { ref, watch } from 'vue'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
/**
|
||||
* @returns {string} - A unique id.
|
||||
*/
|
||||
function getId() {
|
||||
// 生成以act_开头的唯一id
|
||||
return 'act_' + Math.random().toString(36).substr(2, 9)
|
||||
}
|
||||
|
||||
/**
|
||||
* In a real world scenario you'd want to avoid creating refs in a global scope like this as they might not be cleaned up properly.
|
||||
* @type {{draggedData: Ref<any>, isDragOver: Ref<boolean>, isDragging: Ref<boolean>}}
|
||||
*/
|
||||
const state = {
|
||||
/**
|
||||
* The type of the node being dragged.
|
||||
*/
|
||||
draggedData: ref<any | null>({}),
|
||||
isDragOver: ref(false),
|
||||
isDragging: ref(false),
|
||||
}
|
||||
|
||||
export default function useDragAndDrop() {
|
||||
const { draggedData, isDragOver, isDragging } = state
|
||||
|
||||
const { addNodes, screenToFlowCoordinate, onNodesInitialized, updateNode } = useVueFlow()
|
||||
|
||||
watch(isDragging, dragging => {
|
||||
document.body.style.userSelect = dragging ? 'none' : ''
|
||||
})
|
||||
|
||||
function onDragStart(event: any, data: any) {
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.setData('application/vueflow', data)
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
draggedData.value = data
|
||||
isDragging.value = true
|
||||
|
||||
document.addEventListener('drop', onDragEnd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the drag over event.
|
||||
*
|
||||
* @param {DragEvent} event
|
||||
*/
|
||||
function onDragOver(event: any) {
|
||||
event.preventDefault()
|
||||
|
||||
if (draggedData.value) {
|
||||
isDragOver.value = true
|
||||
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
isDragging.value = false
|
||||
isDragOver.value = false
|
||||
draggedData.value = null
|
||||
document.removeEventListener('drop', onDragEnd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the drop event.
|
||||
*
|
||||
* @param {DragEvent} event
|
||||
*/
|
||||
function onDrop(event: any) {
|
||||
const position = screenToFlowCoordinate({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
})
|
||||
|
||||
const nodeId = getId()
|
||||
|
||||
const newNode = {
|
||||
id: nodeId,
|
||||
type: draggedData.value?.type,
|
||||
name: draggedData.value?.name,
|
||||
description: draggedData.value?.description,
|
||||
position,
|
||||
data: draggedData.value?.data ? cloneDeep(draggedData.value.data) : {},
|
||||
}
|
||||
|
||||
/**
|
||||
* Align node position after drop, so it's centered to the mouse
|
||||
*
|
||||
* We can hook into events even in a callback, and we can remove the event listener after it's been called.
|
||||
*/
|
||||
const { off } = onNodesInitialized(() => {
|
||||
updateNode(nodeId, node => ({
|
||||
position: { x: node.position.x - node.dimensions.width / 2, y: node.position.y - node.dimensions.height / 2 },
|
||||
}))
|
||||
|
||||
off()
|
||||
})
|
||||
|
||||
addNodes(newNode)
|
||||
}
|
||||
|
||||
return {
|
||||
draggedData,
|
||||
isDragOver,
|
||||
isDragging,
|
||||
onDragStart,
|
||||
onDragLeave,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
}
|
||||
}
|
||||
@@ -1,92 +1,209 @@
|
||||
<!-- Thanks: https://markus.oberlehner.net/blog/transition-to-height-auto-with-vue/ -->
|
||||
|
||||
<script lang="ts">
|
||||
import { Transition } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import VerticalNav from '@layouts/components/VerticalNav.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TransitionExpand',
|
||||
setup(_, { slots }) {
|
||||
const onEnter = (element: HTMLElement) => {
|
||||
const width = getComputedStyle(element).width
|
||||
setup(props, { slots }) {
|
||||
const isOverlayNavActive = ref(false)
|
||||
const isLayoutOverlayVisible = ref(false)
|
||||
const toggleIsOverlayNavActive = useToggle(isOverlayNavActive)
|
||||
|
||||
element.style.width = width
|
||||
element.style.position = 'absolute'
|
||||
element.style.visibility = 'hidden'
|
||||
element.style.height = 'auto'
|
||||
const route = useRoute()
|
||||
const { mdAndDown } = useDisplay()
|
||||
|
||||
const height = getComputedStyle(element).height
|
||||
// ℹ️ This is alternative to below two commented watcher
|
||||
// We want to show overlay if overlay nav is visible and want to hide overlay if overlay is hidden and vice versa.
|
||||
syncRef(isOverlayNavActive, isLayoutOverlayVisible)
|
||||
|
||||
element.style.width = ''
|
||||
element.style.position = ''
|
||||
element.style.visibility = ''
|
||||
element.style.height = '0px'
|
||||
const scrollDistance = ref(window.scrollY)
|
||||
|
||||
// Force repaint to make sure the
|
||||
// animation is triggered correctly.
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
getComputedStyle(element).height
|
||||
|
||||
// Trigger the animation.
|
||||
// We use `requestAnimationFrame` because we need
|
||||
// to make sure the browser has finished
|
||||
// painting after setting the `height`
|
||||
// to `0` in the line above.
|
||||
requestAnimationFrame(() => {
|
||||
element.style.height = height
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', () => {
|
||||
scrollDistance.value = window.scrollY
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const onAfterEnter = (element: HTMLElement) => {
|
||||
element.style.height = 'auto'
|
||||
}
|
||||
return () => {
|
||||
// 👉 Vertical nav
|
||||
const verticalNav = h(
|
||||
VerticalNav,
|
||||
{ isOverlayNavActive: isOverlayNavActive.value, toggleIsOverlayNavActive },
|
||||
{
|
||||
'nav-header': () => slots['vertical-nav-header']?.(),
|
||||
'before-nav-items': () => slots['before-vertical-nav-items']?.(),
|
||||
'default': () => slots['vertical-nav-content']?.(),
|
||||
'after-nav-items': () => slots['after-vertical-nav-items']?.(),
|
||||
},
|
||||
)
|
||||
|
||||
const onLeave = (element: HTMLElement) => {
|
||||
const height = getComputedStyle(element).height
|
||||
// 👉 Navbar
|
||||
const navbar = h('header', { class: ['layout-navbar navbar-blur'] }, [
|
||||
h(
|
||||
'div',
|
||||
{ class: 'navbar-content-container' },
|
||||
slots.navbar?.({
|
||||
toggleVerticalOverlayNavActive: toggleIsOverlayNavActive,
|
||||
}),
|
||||
),
|
||||
])
|
||||
|
||||
element.style.height = height
|
||||
const main = h(
|
||||
'main',
|
||||
{ class: 'layout-page-content' },
|
||||
h(Transition, { name: 'fade-slide', mode: 'out-in', appear: true }, () =>
|
||||
h('section', { class: 'page-content-container' }, slots.default?.()),
|
||||
),
|
||||
)
|
||||
|
||||
// Force repaint to make sure the
|
||||
// animation is triggered correctly.
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
getComputedStyle(element).height
|
||||
// 👉 根据路由 meta 决定 footer 高度
|
||||
const shouldShowFooter = !route.meta.hideFooter
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
element.style.height = '0px'
|
||||
// 👉 Footer
|
||||
const footer = h('footer', { class: 'layout-footer' }, [
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class: ['footer-content-container', !shouldShowFooter && 'footer-content-container-noheight'],
|
||||
},
|
||||
slots.footer?.(),
|
||||
),
|
||||
])
|
||||
|
||||
// 👉 Overlay
|
||||
const layoutOverlay = h('div', {
|
||||
class: ['layout-overlay', 'touch-none', { visible: isLayoutOverlayVisible.value }],
|
||||
onClick: () => {
|
||||
isLayoutOverlayVisible.value = !isLayoutOverlayVisible.value
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return () => h(
|
||||
h(Transition),
|
||||
{
|
||||
name: 'expand',
|
||||
onEnter,
|
||||
onAfterEnter,
|
||||
onLeave,
|
||||
},
|
||||
() => slots.default?.(),
|
||||
)
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
class: [
|
||||
'layout-wrapper layout-nav-type-vertical layout-navbar-static layout-footer-static layout-content-width-fluid',
|
||||
'layout-navbar-fixed',
|
||||
mdAndDown.value && 'layout-overlay-nav',
|
||||
route.meta.layoutWrapperClasses,
|
||||
scrollDistance.value && 'window-scrolled',
|
||||
],
|
||||
},
|
||||
[verticalNav, h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), layoutOverlay],
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
overflow: hidden;
|
||||
transition: block-size var(--expand-transition-duration, 0.25s) ease;
|
||||
<style lang="scss">
|
||||
@use '@configured-variables' as variables;
|
||||
@use '@layouts/styles/placeholders';
|
||||
@use '@layouts/styles/mixins';
|
||||
|
||||
.layout-wrapper.layout-nav-type-vertical {
|
||||
// TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
|
||||
block-size: 100%;
|
||||
|
||||
.layout-content-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
min-block-size: calc(var(--vh, 1vh) * 100);
|
||||
transition: padding-inline-start 0.2s ease-in-out;
|
||||
will-change: padding-inline-start;
|
||||
}
|
||||
|
||||
.layout-navbar {
|
||||
position: fixed;
|
||||
width: calc(100vw - variables.$layout-vertical-nav-width - 0.5rem);
|
||||
z-index: variables.$layout-vertical-nav-layout-navbar-z-index;
|
||||
inset-block-start: 0;
|
||||
|
||||
.navbar-content-container {
|
||||
block-size: calc(env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height);
|
||||
}
|
||||
|
||||
@at-root {
|
||||
.layout-wrapper.layout-nav-type-vertical {
|
||||
.layout-navbar {
|
||||
@if variables.$layout-vertical-nav-navbar-is-contained {
|
||||
@include mixins.boxed-content;
|
||||
} @else {
|
||||
.navbar-content-container {
|
||||
@include mixins.boxed-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.layout-navbar-fixed .layout-navbar {
|
||||
@extend %layout-navbar-fixed;
|
||||
}
|
||||
|
||||
&.layout-navbar-hidden .layout-navbar {
|
||||
@extend %layout-navbar-hidden;
|
||||
}
|
||||
|
||||
// 👉 Footer
|
||||
.layout-footer {
|
||||
@include mixins.boxed-content;
|
||||
}
|
||||
|
||||
// 👉 Layout overlay
|
||||
.layout-overlay {
|
||||
position: fixed;
|
||||
z-index: variables.$layout-overlay-z-index;
|
||||
background-color: rgb(0 0 0 / 60%);
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s ease-in-out;
|
||||
will-change: transform;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.layout-overlay-nav) .layout-content-wrapper {
|
||||
padding-inline-start: variables.$layout-vertical-nav-width;
|
||||
}
|
||||
|
||||
// Adjust right column pl when vertical nav is collapsed
|
||||
&.layout-vertical-nav-collapsed .layout-content-wrapper {
|
||||
padding-inline-start: variables.$layout-vertical-nav-collapsed-width;
|
||||
}
|
||||
|
||||
// 👉 Content height fixed
|
||||
&.layout-content-height-fixed {
|
||||
.layout-content-wrapper {
|
||||
max-block-size: calc(var(--vh) * 100);
|
||||
}
|
||||
|
||||
.layout-page-content {
|
||||
// display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
.page-content-container {
|
||||
inline-size: 100%;
|
||||
|
||||
> :first-child {
|
||||
max-block-size: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.expand-enter-from,
|
||||
.expand-leave-to {
|
||||
block-size: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
* {
|
||||
backface-visibility: hidden;
|
||||
perspective: 62.5rem;
|
||||
transform: translateZ(0);
|
||||
will-change: block-size;
|
||||
.layout-wrapper.layout-nav-type-vertical.layout-overlay-nav {
|
||||
.layout-navbar {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -59,9 +59,6 @@ function handleNavScroll(evt: Event) {
|
||||
</RouterLink>
|
||||
</slot>
|
||||
</div>
|
||||
<slot name="before-nav-items">
|
||||
<div class="vertical-nav-items-shadow" />
|
||||
</slot>
|
||||
<slot name="nav-items" :update-is-vertical-nav-scrolled="updateIsVerticalNavScrolled">
|
||||
<PerfectScrollbar
|
||||
tag="ul"
|
||||
|
||||
@@ -51,14 +51,23 @@ export default defineComponent({
|
||||
const main = h(
|
||||
'main',
|
||||
{ class: 'layout-page-content' },
|
||||
h(Transition, { name: 'fade-slide', mode: 'out-in', appear: true },
|
||||
() => h('section', { class: 'page-content-container' }, slots.default?.()),
|
||||
h(Transition, { name: 'fade-slide', mode: 'out-in', appear: true }, () =>
|
||||
h('section', { class: 'page-content-container' }, slots.default?.()),
|
||||
),
|
||||
)
|
||||
|
||||
// 👉 根据路由 meta 决定 footer 高度
|
||||
const shouldShowFooter = !route.meta.hideFooter
|
||||
|
||||
// 👉 Footer
|
||||
const footer = h('footer', { class: 'layout-footer' }, [
|
||||
h('div', { class: 'footer-content-container' }, slots.footer?.()),
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class: ['footer-content-container', !shouldShowFooter && 'footer-content-container-noheight'],
|
||||
},
|
||||
slots.footer?.(),
|
||||
),
|
||||
])
|
||||
|
||||
// 👉 Overlay
|
||||
@@ -80,11 +89,7 @@ export default defineComponent({
|
||||
scrollDistance.value && 'window-scrolled',
|
||||
],
|
||||
},
|
||||
[
|
||||
verticalNav,
|
||||
h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]),
|
||||
layoutOverlay,
|
||||
],
|
||||
[verticalNav, h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), layoutOverlay],
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -92,9 +97,16 @@ export default defineComponent({
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use "@configured-variables" as variables;
|
||||
@use "@layouts/styles/placeholders";
|
||||
@use "@layouts/styles/mixins";
|
||||
@use '@configured-variables' as variables;
|
||||
@use '@layouts/styles/placeholders';
|
||||
@use '@layouts/styles/mixins';
|
||||
|
||||
.layout-page-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-block-start: 0;
|
||||
padding-block-start: 0;
|
||||
}
|
||||
|
||||
.layout-wrapper.layout-nav-type-vertical {
|
||||
// TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
|
||||
@@ -111,14 +123,12 @@ export default defineComponent({
|
||||
|
||||
.layout-navbar {
|
||||
position: fixed;
|
||||
width: calc(100vw - variables.$layout-vertical-nav-width - 0.5rem);
|
||||
z-index: variables.$layout-vertical-nav-layout-navbar-z-index;
|
||||
inline-size: calc(100vw - variables.$layout-vertical-nav-width - 1rem);
|
||||
inset-block-start: 0;
|
||||
|
||||
.navbar-content-container {
|
||||
block-size: calc(
|
||||
env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height
|
||||
);
|
||||
block-size: calc(env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height);
|
||||
}
|
||||
|
||||
@at-root {
|
||||
@@ -168,7 +178,7 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
&:not(.layout-overlay-nav) .layout-content-wrapper {
|
||||
padding-inline-start: variables.$layout-vertical-nav-width;
|
||||
padding-inline-start: calc(variables.$layout-vertical-nav-width + 8px);
|
||||
}
|
||||
|
||||
// Adjust right column pl when vertical nav is collapsed
|
||||
@@ -200,7 +210,9 @@ export default defineComponent({
|
||||
|
||||
.layout-wrapper.layout-nav-type-vertical.layout-overlay-nav {
|
||||
.layout-navbar {
|
||||
width: 100%;
|
||||
inline-size: calc(100% - 0px);
|
||||
margin-inline-start: 8px;
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,19 +7,9 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
class="nav-link"
|
||||
:class="{ disabled: item.disable }"
|
||||
>
|
||||
<Component
|
||||
:is="item.to ? 'RouterLink' : 'a'"
|
||||
:to="item.to"
|
||||
:href="item.href"
|
||||
>
|
||||
<VIcon
|
||||
:icon="item.icon"
|
||||
class="nav-item-icon"
|
||||
/>
|
||||
<li class="nav-link" :class="{ disabled: item.disable }">
|
||||
<Component :is="item.to ? 'RouterLink' : 'a'" :to="item.to" :href="item.href">
|
||||
<VIcon :icon="item.icon as string" class="nav-item-icon" />
|
||||
<!-- 👉 Title -->
|
||||
<span class="nav-item-title">
|
||||
{{ item.title }}
|
||||
|
||||
@@ -10,10 +10,7 @@ defineProps<{
|
||||
<li class="nav-section-title">
|
||||
<div class="title-wrapper">
|
||||
<!-- eslint-disable vue/no-v-text-v-html-on-component -->
|
||||
<span
|
||||
class="title-text"
|
||||
v-text="item.heading"
|
||||
/>
|
||||
<span class="title-text" v-text="item.heading" />
|
||||
<!-- eslint-enable vue/no-v-text-v-html-on-component -->
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -5,58 +5,60 @@
|
||||
@use "@configured-variables" as variables;
|
||||
|
||||
html {
|
||||
min-height: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));
|
||||
background: rgb(var(--v-theme-background));
|
||||
min-block-size: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));
|
||||
overflow-y: overlay;
|
||||
}
|
||||
|
||||
body {
|
||||
overscroll-behavior-y: contain;
|
||||
--webkit-overflow-scrolling: touch;
|
||||
background: rgb(var(--v-theme-background));
|
||||
overscroll-behavior-y: contain;
|
||||
|
||||
--webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
body,
|
||||
#app,
|
||||
.v-application {
|
||||
min-height: 100%;
|
||||
min-block-size: 100%;
|
||||
}
|
||||
|
||||
.layout-vertical-nav {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
padding-block: env(safe-area-inset-top) env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.navbar-content-container {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-block-start: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.layout-page-content {
|
||||
@include mixins.boxed-content(true);
|
||||
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
// TODO: Use grid gutter variable here
|
||||
flex-grow: 1;
|
||||
|
||||
// TODO: Use grid gutter variable here;
|
||||
padding-block: 1.5rem;
|
||||
padding-top: calc(env(safe-area-inset-top) + 4.25rem);
|
||||
// display: flex;
|
||||
padding-block-start: calc(env(safe-area-inset-top) + 4.25rem);
|
||||
|
||||
// display: flex;display
|
||||
|
||||
|
||||
.page-content-container {
|
||||
// flex: 1;
|
||||
// flex: 1;flex
|
||||
display: flex;
|
||||
|
||||
& > div:first-child {
|
||||
flex: auto;
|
||||
position: relative;
|
||||
width: calc(100vw - variables.$layout-vertical-nav-width - 0.5rem);
|
||||
flex: auto;
|
||||
inline-size: calc(100vw - variables.$layout-vertical-nav-width - 0.5rem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1280px){
|
||||
@media screen and (width <= 1280px){
|
||||
.page-content-container > div:first-child {
|
||||
width: calc(100vw - 1rem) !important;
|
||||
inline-size: calc(100vw - 1rem) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +67,10 @@ body,
|
||||
block-size: variables.$layout-vertical-nav-footer-height;
|
||||
}
|
||||
|
||||
.footer-content-container-noheight {
|
||||
block-size: 0 !important;
|
||||
}
|
||||
|
||||
.layout-footer-sticky & {
|
||||
position: sticky;
|
||||
inset-block-end: 0;
|
||||
|
||||
@@ -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: 0rem !default;
|
||||
$layout-vertical-nav-footer-height: 3.5rem !default;
|
||||
|
||||
// 👉 Layout overlay
|
||||
$layout-overlay-z-index: 11 !default;
|
||||
|
||||
33
src/App.vue
33
src/App.vue
@@ -1,15 +1,16 @@
|
||||
<script lang="ts" setup>
|
||||
import { useTheme } from 'vuetify'
|
||||
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
|
||||
|
||||
const { global: globalTheme } = useTheme()
|
||||
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
|
||||
|
||||
// 生效主题
|
||||
async function setTheme() {
|
||||
let themeValue = localStorage.getItem('theme') || 'light'
|
||||
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
|
||||
}
|
||||
const { global: globalTheme } = useTheme()
|
||||
let themeValue = localStorage.getItem('theme') || 'light'
|
||||
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
|
||||
|
||||
// 显示状态
|
||||
const show = ref(false)
|
||||
|
||||
// ApexCharts 全局配置
|
||||
declare global {
|
||||
@@ -41,14 +42,24 @@ if (window.Apex) {
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时,加载当前用户数据
|
||||
onBeforeMount(async () => {
|
||||
setTheme()
|
||||
onMounted(() => {
|
||||
ensureRenderComplete(() => {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
// 移除加载动画
|
||||
removeEl('#loading-bg')
|
||||
// 将background属性从html的style中移除
|
||||
document.documentElement.style.removeProperty('background')
|
||||
// 显示页面
|
||||
show.value = true
|
||||
}, 1500)
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VApp>
|
||||
<VApp v-show="show">
|
||||
<RouterView />
|
||||
</VApp>
|
||||
</template>
|
||||
|
||||
@@ -3,26 +3,31 @@ export const storageOptions = [
|
||||
title: '本地',
|
||||
value: 'local',
|
||||
icon: 'mdi-folder-multiple-outline',
|
||||
remote: false,
|
||||
},
|
||||
{
|
||||
title: '阿里云盘',
|
||||
value: 'alipan',
|
||||
icon: 'mdi-cloud-outline',
|
||||
remote: true,
|
||||
},
|
||||
{
|
||||
title: '115网盘',
|
||||
value: 'u115',
|
||||
icon: 'mdi-cloud-outline',
|
||||
remote: true,
|
||||
},
|
||||
{
|
||||
title: 'RClone',
|
||||
value: 'rclone',
|
||||
icon: 'mdi-cloud-outline',
|
||||
remote: true,
|
||||
},
|
||||
{
|
||||
title: 'AList',
|
||||
value: 'alist',
|
||||
icon: 'mdi-cloud-outline',
|
||||
remote: true,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from 'axios'
|
||||
import router from '@/router'
|
||||
import store from '@/store'
|
||||
import { useAuthStore } from '@/stores'
|
||||
|
||||
// 创建axios实例
|
||||
const api = axios.create({
|
||||
@@ -9,10 +9,12 @@ const api = axios.create({
|
||||
|
||||
// 添加请求拦截器
|
||||
api.interceptors.request.use(config => {
|
||||
// 认证 Store
|
||||
const authStore = useAuthStore()
|
||||
// 在请求头中添加token
|
||||
const token = store.state.auth.token
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
|
||||
if (authStore.token) {
|
||||
config.headers.Authorization = `Bearer ${authStore.token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
@@ -26,8 +28,10 @@ api.interceptors.response.use(
|
||||
// 请求超时
|
||||
return Promise.reject(new Error(error))
|
||||
} else if (error.response.status === 403) {
|
||||
// 认证 Store
|
||||
const authStore = useAuthStore()
|
||||
// 清除登录状态信息
|
||||
store.dispatch('auth/logout')
|
||||
authStore.logout()
|
||||
// token验证失败,跳转到登录页面
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ export interface Subscribe {
|
||||
tmdbid: number
|
||||
// 豆瓣ID
|
||||
doubanid?: string
|
||||
// Bangumi ID
|
||||
bangumiid?: string
|
||||
// 其它媒体ID
|
||||
mediaid?: string
|
||||
// 季号
|
||||
season?: number
|
||||
// 海报
|
||||
@@ -73,7 +77,9 @@ export interface Subscribe {
|
||||
// 过滤规则组
|
||||
filter_groups?: string[]
|
||||
// 下载器
|
||||
downloader: string
|
||||
downloader?: string
|
||||
// 自定义剧集组
|
||||
episode_group?: string
|
||||
}
|
||||
|
||||
// 订阅分享
|
||||
@@ -134,6 +140,8 @@ export interface SubscribeShare {
|
||||
media_category?: string
|
||||
// 复用次数
|
||||
count?: number
|
||||
// 自定义剧集组
|
||||
episode_group?: string
|
||||
}
|
||||
|
||||
// 历史记录
|
||||
@@ -180,6 +188,8 @@ export interface TransferHistory {
|
||||
errmsg?: string
|
||||
// 日期
|
||||
date?: string
|
||||
// 源文件项
|
||||
src_fileitem?: FileItem
|
||||
}
|
||||
|
||||
// 媒体信息
|
||||
@@ -208,6 +218,10 @@ export interface MediaInfo {
|
||||
bangumi_id?: string
|
||||
// 合集ID
|
||||
collection_id?: number
|
||||
// 其它媒体ID前缀
|
||||
mediaid_prefix?: string
|
||||
// 其它媒体ID值
|
||||
media_id?: string
|
||||
// 媒体原语种
|
||||
original_language?: string
|
||||
// 媒体原发行标题
|
||||
@@ -278,6 +292,26 @@ export interface MediaInfo {
|
||||
next_episode_to_air?: object
|
||||
// 别名
|
||||
names?: string[]
|
||||
// 剧集组
|
||||
episode_group?: string
|
||||
}
|
||||
|
||||
// 季信息
|
||||
export interface MediaSeason {
|
||||
// 上映日期
|
||||
air_date?: string
|
||||
// 总集数
|
||||
episode_count?: number
|
||||
// 季名称
|
||||
name?: string
|
||||
// 描述
|
||||
overview?: string
|
||||
// 海报
|
||||
poster_path?: string
|
||||
// 季号
|
||||
season_number?: number
|
||||
// 评分
|
||||
vote_average?: number
|
||||
}
|
||||
|
||||
// TMDB季信息
|
||||
@@ -769,6 +803,8 @@ export interface User {
|
||||
permissions: { [key: string]: any }
|
||||
// 用户个性化设置 json
|
||||
settings: { [key: string]: string | null }
|
||||
// 昵称
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
// 存储空间
|
||||
@@ -1186,6 +1222,8 @@ export interface TransferForm {
|
||||
library_type_folder?: boolean
|
||||
// 媒体库类别子目录
|
||||
library_category_folder?: boolean
|
||||
// 剧集组编号
|
||||
episode_group?: string
|
||||
}
|
||||
|
||||
// 整理队列
|
||||
@@ -1204,3 +1242,62 @@ export interface TransferQueue {
|
||||
state: string
|
||||
}[]
|
||||
}
|
||||
|
||||
// 探索的数据源
|
||||
export interface DiscoverSource {
|
||||
// 数据源名称
|
||||
name: string
|
||||
// 媒体ID的前缀,不含:
|
||||
mediaid_prefix: string
|
||||
// 媒体数据源API地址
|
||||
api_path: string
|
||||
// 过滤参数
|
||||
filter_params: { [key: string]: any }
|
||||
// 过滤参数UI配置
|
||||
filter_ui: RenderProps[]
|
||||
// UI依赖关系字典
|
||||
depends?: { [key: string]: string[] }
|
||||
}
|
||||
|
||||
// 推荐的数据源
|
||||
export interface RecommendSource {
|
||||
// 数据源名称
|
||||
name: string
|
||||
// 媒体数据源API地址
|
||||
api_path: string
|
||||
}
|
||||
|
||||
// 站点资源分类
|
||||
export interface SiteCategory {
|
||||
id: number
|
||||
cat: string
|
||||
desc: string
|
||||
}
|
||||
|
||||
// 工作流
|
||||
export interface Workflow {
|
||||
// 工作流ID
|
||||
id?: string
|
||||
// 工作流名称
|
||||
name?: string
|
||||
// 工作流描述
|
||||
description?: string
|
||||
// 定时器
|
||||
timer?: string
|
||||
// 状态
|
||||
state?: string
|
||||
// 当前执行动作
|
||||
current_action?: string
|
||||
// 任务执行结果
|
||||
result?: string
|
||||
// 已执行次数
|
||||
run_count?: number
|
||||
// 动作列表
|
||||
actions?: any[]
|
||||
// 动作流
|
||||
flows?: any[]
|
||||
// 创建时间
|
||||
add_time?: string
|
||||
// 最后执行时间
|
||||
last_time?: string
|
||||
}
|
||||
|
||||
BIN
src/assets/images/logos/bangumi_title.png
Normal file
BIN
src/assets/images/logos/bangumi_title.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
BIN
src/assets/images/logos/douban_title.png
Normal file
BIN
src/assets/images/logos/douban_title.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
1
src/assets/images/logos/tmdb_title.svg
Normal file
1
src/assets/images/logos/tmdb_title.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 273.42 35.52"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="17.76" x2="273.42" y2="17.76" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>Asset 3</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M191.85,35.37h63.9A17.67,17.67,0,0,0,273.42,17.7h0A17.67,17.67,0,0,0,255.75,0h-63.9A17.67,17.67,0,0,0,174.18,17.7h0A17.67,17.67,0,0,0,191.85,35.37ZM10.1,35.42h7.8V6.92H28V0H0v6.9H10.1Zm28.1,0H46V8.25h.1L55.05,35.4h6L70.3,8.25h.1V35.4h7.8V0H66.45l-8.2,23.1h-.1L50,0H38.2ZM89.14.12h11.7a33.56,33.56,0,0,1,8.08,1,18.52,18.52,0,0,1,6.67,3.08,15.09,15.09,0,0,1,4.53,5.52,18.5,18.5,0,0,1,1.67,8.25,16.91,16.91,0,0,1-1.62,7.58,16.3,16.3,0,0,1-4.38,5.5,19.24,19.24,0,0,1-6.35,3.37,24.53,24.53,0,0,1-7.55,1.15H89.14Zm7.8,28.2h4a21.66,21.66,0,0,0,5-.55A10.58,10.58,0,0,0,110,26a8.73,8.73,0,0,0,2.68-3.35,11.9,11.9,0,0,0,1-5.08,9.87,9.87,0,0,0-1-4.52,9.17,9.17,0,0,0-2.63-3.18A11.61,11.61,0,0,0,106.22,8a17.06,17.06,0,0,0-4.68-.63h-4.6ZM133.09.12h13.2a32.87,32.87,0,0,1,4.63.33,12.66,12.66,0,0,1,4.17,1.3,7.94,7.94,0,0,1,3,2.72,8.34,8.34,0,0,1,1.15,4.65,7.48,7.48,0,0,1-1.67,5,9.13,9.13,0,0,1-4.43,2.82V17a10.28,10.28,0,0,1,3.18,1,8.51,8.51,0,0,1,2.45,1.85,7.79,7.79,0,0,1,1.57,2.62,9.16,9.16,0,0,1,.55,3.2,8.52,8.52,0,0,1-1.2,4.68,9.32,9.32,0,0,1-3.1,3A13.38,13.38,0,0,1,152.32,35a22.5,22.5,0,0,1-4.73.5h-14.5Zm7.8,14.15h5.65a7.65,7.65,0,0,0,1.78-.2,4.78,4.78,0,0,0,1.57-.65,3.43,3.43,0,0,0,1.13-1.2,3.63,3.63,0,0,0,.42-1.8A3.3,3.3,0,0,0,151,8.6a3.42,3.42,0,0,0-1.23-1.13A6.07,6.07,0,0,0,148,6.9a9.9,9.9,0,0,0-1.85-.18h-5.3Zm0,14.65h7a8.27,8.27,0,0,0,1.83-.2,4.67,4.67,0,0,0,1.67-.7,3.93,3.93,0,0,0,1.23-1.3,3.8,3.8,0,0,0,.47-1.95,3.16,3.16,0,0,0-.62-2,4,4,0,0,0-1.58-1.18,8.23,8.23,0,0,0-2-.55,15.12,15.12,0,0,0-2.05-.15h-5.9Z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src/assets/images/logos/trimemedia.png
Normal file
BIN
src/assets/images/logos/trimemedia.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -2,8 +2,10 @@
|
||||
import type { Axios } from 'axios'
|
||||
import FileList from './filebrowser/FileList.vue'
|
||||
import FileToolbar from './filebrowser/FileToolbar.vue'
|
||||
import FileNavigator from './filebrowser/FileNavigator.vue'
|
||||
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
|
||||
import { storageOptions } from '@/api/constants'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -28,6 +30,12 @@ const props = defineProps({
|
||||
// 对外事件
|
||||
const emit = defineEmits(['pathchanged'])
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// APP
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
|
||||
const fileIcons = {
|
||||
// 压缩包
|
||||
zip: 'mdi-folder-zip-outline',
|
||||
@@ -126,6 +134,9 @@ const refreshPending = ref(false)
|
||||
// 排序
|
||||
const sort = ref('name')
|
||||
|
||||
// 是否显示目录树
|
||||
const showDirTree = ref(false)
|
||||
|
||||
// 计算属性
|
||||
const storagesArray = computed(() => {
|
||||
const storageCodes = props.storages?.map(item => item.type)
|
||||
@@ -154,10 +165,37 @@ function sortChanged(s: string) {
|
||||
sort.value = s
|
||||
refreshPending.value = true
|
||||
}
|
||||
|
||||
// 切换目录树
|
||||
function switchDirTree(state: boolean) {
|
||||
showDirTree.value = state
|
||||
}
|
||||
|
||||
// 文件列表
|
||||
const fileListItems = ref<FileItem[]>([])
|
||||
|
||||
// 文件列表数据更新
|
||||
function fileListUpdated(items: FileItem[]) {
|
||||
fileListItems.value = items
|
||||
}
|
||||
|
||||
// 外层DIV大小控制
|
||||
const scrollStyle = computed(() => {
|
||||
return appMode
|
||||
? 'height: calc(100vh - 10rem - env(safe-area-inset-bottom) - 6rem)'
|
||||
: 'height: calc(100vh - 10rem - env(safe-area-inset-bottom)'
|
||||
})
|
||||
|
||||
// 文件列表大小限制
|
||||
const fileListStyle = computed(() => {
|
||||
return appMode
|
||||
? 'height: calc(100vh - 14rem - env(safe-area-inset-bottom) - 6rem)'
|
||||
: 'height: calc(100vh - 14rem - env(safe-area-inset-bottom)'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="mx-auto" :loading="loading > 0">
|
||||
<div class="mx-auto" :loading="loading > 0">
|
||||
<div v-if="activeStorage && item">
|
||||
<FileToolbar
|
||||
:item="item"
|
||||
@@ -171,20 +209,36 @@ function sortChanged(s: string) {
|
||||
@foldercreated="refreshPending = true"
|
||||
@sortchanged="sortChanged"
|
||||
/>
|
||||
<FileList
|
||||
:item="item"
|
||||
:storage="activeStorage"
|
||||
:icons="fileIcons"
|
||||
:endpoints="endpoints"
|
||||
:axios="axios"
|
||||
:refreshpending="refreshPending"
|
||||
:sort="sort"
|
||||
@pathchanged="pathChanged"
|
||||
@loading="loadingChanged"
|
||||
@refreshed="refreshPending = false"
|
||||
@filedeleted="refreshPending = true"
|
||||
@renamed="refreshPending = true"
|
||||
/>
|
||||
<div class="flex" :style="scrollStyle">
|
||||
<FileNavigator
|
||||
v-if="showDirTree"
|
||||
:storage="activeStorage"
|
||||
:currentPath="item.path"
|
||||
:items="fileListItems"
|
||||
:endpoints="endpoints"
|
||||
:axios="axios"
|
||||
@navigate="pathChanged"
|
||||
/>
|
||||
<FileList
|
||||
class="flex-grow"
|
||||
:item="item"
|
||||
:storage="activeStorage"
|
||||
:icons="fileIcons"
|
||||
:endpoints="endpoints"
|
||||
:axios="axios"
|
||||
:refreshpending="refreshPending"
|
||||
:sort="sort"
|
||||
:listStyle="fileListStyle"
|
||||
:showTree="showDirTree"
|
||||
@pathchanged="pathChanged"
|
||||
@loading="loadingChanged"
|
||||
@refreshed="refreshPending = false"
|
||||
@filedeleted="refreshPending = true"
|
||||
@renamed="refreshPending = true"
|
||||
@items-updated="fileListUpdated"
|
||||
@switch-tree="switchDirTree"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,31 +1,244 @@
|
||||
<script setup lang="ts">
|
||||
import image from '@images/no-data.svg'
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
interface Props {
|
||||
errorCode?: string
|
||||
errorTitle?: string
|
||||
errorDescription?: string
|
||||
icon?: string
|
||||
iconColor?: string
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VEmptyState :image="image" size="250">
|
||||
<template #title>
|
||||
<div class="mt-8 text-2xl">
|
||||
{{ props.errorTitle }}
|
||||
<div class="no-data-container">
|
||||
<!-- 图标容器 -->
|
||||
<div class="icon-wrapper">
|
||||
<div class="icon-glow"></div>
|
||||
<div class="icon-container">
|
||||
<VIcon
|
||||
:icon="props.icon || 'mdi-file-search-outline'"
|
||||
:color="props.iconColor || 'white'"
|
||||
size="48"
|
||||
class="main-icon"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div class="pulse-ring"></div>
|
||||
</div>
|
||||
|
||||
<template #text>
|
||||
<div class="text-subtitle mt-3">
|
||||
{{ props.errorDescription }}
|
||||
</div>
|
||||
</template>
|
||||
<!-- 标题 -->
|
||||
<div class="error-title">
|
||||
{{ props.errorTitle || '暂无数据' }}
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<!-- 描述 -->
|
||||
<div class="error-description">
|
||||
{{ props.errorDescription || '没有找到相关内容' }}
|
||||
</div>
|
||||
|
||||
<!-- 按钮插槽 -->
|
||||
<div class="actions-container">
|
||||
<slot name="button" />
|
||||
</template>
|
||||
</VEmptyState>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.no-data-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 100%;
|
||||
min-block-size: 300px;
|
||||
padding-block: 3rem;
|
||||
padding-inline: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 图标样式 */
|
||||
.icon-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
block-size: 100px;
|
||||
inline-size: 100px;
|
||||
margin-block: 0 2rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.icon-glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
animation: pulse 3s infinite ease-in-out;
|
||||
background: radial-gradient(circle, rgba(var(--v-theme-primary), 0.8) 0%, rgba(var(--v-theme-primary), 0) 70%);
|
||||
block-size: 80px;
|
||||
filter: blur(15px);
|
||||
inline-size: 80px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.9), rgba(var(--v-theme-secondary), 0.8));
|
||||
block-size: 80px;
|
||||
inline-size: 80px;
|
||||
}
|
||||
|
||||
.main-icon {
|
||||
animation: slight-bounce 3s infinite ease-in-out;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 30%));
|
||||
}
|
||||
|
||||
.pulse-ring {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
border: 2px solid rgba(var(--v-theme-primary), 0.5);
|
||||
border-radius: 50%;
|
||||
animation: ripple 2s infinite ease-out;
|
||||
block-size: 100px;
|
||||
inline-size: 100px;
|
||||
inset-block-start: 50%;
|
||||
inset-inline-start: 50%;
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.pulse-ring::before {
|
||||
position: absolute;
|
||||
border: 2px solid rgba(var(--v-theme-primary), 0.3);
|
||||
border-radius: 50%;
|
||||
animation: ripple 2s infinite 0.5s ease-out;
|
||||
block-size: 85px;
|
||||
content: '';
|
||||
inline-size: 85px;
|
||||
inset-block-start: 50%;
|
||||
inset-inline-start: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
@keyframes ripple {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(1.5);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slight-bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 文字样式 */
|
||||
.error-title {
|
||||
position: relative;
|
||||
color: rgba(var(--v-theme-on-surface), 0.95);
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin-block-end: 0.75rem;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 5%);
|
||||
}
|
||||
|
||||
.error-title::after {
|
||||
display: block;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(90deg, rgba(var(--v-theme-primary), 0.8), rgba(var(--v-theme-primary), 0.2));
|
||||
block-size: 3px;
|
||||
content: '';
|
||||
inline-size: 40px;
|
||||
margin-block: 0.5rem 0;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.error-description {
|
||||
color: rgba(var(--v-theme-on-surface), 0.75);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
margin-block-end: 1.5rem;
|
||||
margin-inline: auto;
|
||||
max-inline-size: 80%;
|
||||
}
|
||||
|
||||
.actions-container {
|
||||
margin-block-start: 1.5rem;
|
||||
}
|
||||
|
||||
.actions-container :deep(.v-btn) {
|
||||
transform: translateY(0);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.actions-container :deep(.v-btn:hover) {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (width <= 600px) {
|
||||
.no-data-container {
|
||||
padding-block: 2rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
block-size: 80px;
|
||||
inline-size: 80px;
|
||||
margin-block-end: 1.5rem;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
block-size: 70px;
|
||||
inline-size: 70px;
|
||||
}
|
||||
|
||||
.icon-glow {
|
||||
block-size: 70px;
|
||||
inline-size: 70px;
|
||||
}
|
||||
|
||||
.pulse-ring,
|
||||
.pulse-ring::before {
|
||||
block-size: 80px;
|
||||
inline-size: 80px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.error-description {
|
||||
font-size: 0.95rem;
|
||||
max-inline-size: 90%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -37,7 +37,7 @@ const getImgUrl = computed(() => {
|
||||
:width="props.width"
|
||||
class="ring-gray-500"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
|
||||
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
|
||||
'ring-1': imageLoaded,
|
||||
}"
|
||||
@click="goPlay"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { CustomRule } from '@/api/types'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import filter_svg from '@images/svg/filter.svg'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { innerFilterRules } from '@/api/constants'
|
||||
|
||||
// 输入参数
|
||||
@@ -104,7 +104,7 @@ function onClose() {
|
||||
<VImg :src="filter_svg" cover class="mt-7" max-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-model="ruleInfoDialog" scrollable max-width="40rem" persistent>
|
||||
<VDialog v-if="ruleInfoDialog" v-model="ruleInfoDialog" scrollable max-width="40rem" persistent>
|
||||
<VCard :title="`${props.rule.id} - 配置`" class="rounded-t">
|
||||
<DialogCloseBtn v-model="ruleInfoDialog" />
|
||||
<VDivider />
|
||||
|
||||
@@ -29,6 +29,11 @@ const typeItems = [
|
||||
{ title: '电视剧', value: '电视剧' },
|
||||
]
|
||||
|
||||
// 计算资源存储字典(整理方式为下载器时不能为远程存储)
|
||||
const resourceStorageOptions = computed(() => {
|
||||
return storageOptions.filter(item => !item.remote || props.directory.monitor_type !== 'downloader')
|
||||
})
|
||||
|
||||
// 自动整理方式下拉字典
|
||||
const transferSourceItems = [
|
||||
{ title: '不整理', value: '' },
|
||||
@@ -131,7 +136,7 @@ const getCategories = computed(() => {
|
||||
return default_value.concat(props.categories[props.directory.media_type ?? ''])
|
||||
})
|
||||
|
||||
// 监听 下载储存与媒体库储存 变化,重新加载整理方式下拉字典
|
||||
// 监听 资源存储与媒体库储存 变化,重新加载整理方式下拉字典
|
||||
watch(
|
||||
[() => props.directory.library_storage, () => props.directory.storage],
|
||||
([newLibraryStorage, newStorage], [oldLibraryStorage, oldStorage]) => {
|
||||
@@ -156,6 +161,16 @@ watch(
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 监听monitor_type变化,如果为downloader则设置为本地
|
||||
watch(
|
||||
() => props.directory.monitor_type,
|
||||
newMonitorType => {
|
||||
if (newMonitorType === 'downloader') {
|
||||
props.directory.storage = 'local'
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -198,8 +213,8 @@ watch(
|
||||
<VSelect
|
||||
v-model="props.directory.storage"
|
||||
variant="underlined"
|
||||
:items="storageOptions"
|
||||
label="下载存储/源存储"
|
||||
:items="resourceStorageOptions"
|
||||
label="资源存储"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="8">
|
||||
@@ -207,7 +222,7 @@ watch(
|
||||
v-model="props.directory.download_path"
|
||||
:storage="props.directory.storage"
|
||||
variant="underlined"
|
||||
label="下载目录/源目录"
|
||||
label="资源目录"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" v-if="!props.directory.media_type || props.directory.media_type === ''">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useToast } from 'vue-toast-notification'
|
||||
import type { DownloaderInfo } from '@/api/types'
|
||||
import qbittorrent_image from '@images/logos/qbittorrent.png'
|
||||
import transmission_image from '@images/logos/transmission.png'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
@@ -104,7 +104,7 @@ function saveDownloaderInfo() {
|
||||
props.downloaders.forEach(item => {
|
||||
if (item.default && item !== props.downloader) {
|
||||
item.default = false
|
||||
$toast.info(`【${item.name}】存在默认下载器,已替换成【${downloaderInfo.value.name}】`)
|
||||
$toast.info(`存在默认下载器【${item.name}】,已替换成【${downloaderInfo.value.name}】`)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -143,36 +143,43 @@ onUnmounted(() => {
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="openDownloaderInfoDialog">
|
||||
<DialogCloseBtn @click="onClose" />
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VCardText class="flex justify-space-between align-center gap-4">
|
||||
<div class="align-self-start flex-1">
|
||||
<div class="flex items-center">
|
||||
<VBadge
|
||||
v-if="props.downloader.default && props.downloader.enabled"
|
||||
dot
|
||||
inline
|
||||
color="success"
|
||||
class="me-1"
|
||||
/>
|
||||
<span class="text-h6">{{ downloader.name }}</span>
|
||||
<VHover v-slot="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
variant="tonal"
|
||||
@click="openDownloaderInfoDialog"
|
||||
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
|
||||
>
|
||||
<DialogCloseBtn @click="onClose" />
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VCardText class="flex justify-space-between align-center gap-4">
|
||||
<div class="align-self-start flex-1">
|
||||
<div class="flex items-center">
|
||||
<VBadge
|
||||
v-if="props.downloader.default && props.downloader.enabled"
|
||||
dot
|
||||
inline
|
||||
color="success"
|
||||
class="me-1"
|
||||
/>
|
||||
<span class="text-h6">{{ downloader.name }}</span>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap text-sm" v-if="props.downloader.enabled">
|
||||
<span class="me-2">{{ `↑ ${formatFileSize(upload_rate, 1)}/s ` }}</span>
|
||||
<span>{{ `↓ ${formatFileSize(download_rate, 1)}/s` }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap text-sm" v-if="props.downloader.enabled">
|
||||
<span class="me-2">{{ `↑ ${formatFileSize(upload_rate, 1)}/s ` }}</span>
|
||||
<span>{{ `↓ ${formatFileSize(download_rate, 1)}/s` }}</span>
|
||||
<div class="h-20">
|
||||
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-20">
|
||||
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-model="downloaderInfoDialog" scrollable max-width="40rem" persistent>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VHover>
|
||||
<VDialog v-if="downloaderInfoDialog" v-model="downloaderInfoDialog" scrollable max-width="40rem" persistent>
|
||||
<VCard :title="`${props.downloader.name} - 配置`" class="rounded-t">
|
||||
<DialogCloseBtn v-model="downloaderInfoDialog" />
|
||||
<VDivider />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { innerFilterRules } from '@/api/constants'
|
||||
import { CustomRule } from '@/api/types'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
|
||||
@@ -6,7 +6,7 @@ import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
|
||||
import filter_group_svg from '@images/svg/filter-group.svg'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -220,7 +220,7 @@ function onClose() {
|
||||
<VImg :src="filter_group_svg" cover class="mt-10" max-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-model="groupInfoDialog" scrollable max-width="80rem" persistent>
|
||||
<VDialog v-if="groupInfoDialog" v-model="groupInfoDialog" scrollable max-width="80rem" persistent>
|
||||
<VCard :title="`${props.group.name} - 配置`" class="rounded-t">
|
||||
<DialogCloseBtn v-model="groupInfoDialog" />
|
||||
<VDivider />
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { MediaServerLibrary } from '@/api/types'
|
||||
import plex from '@images/misc/plex.png'
|
||||
import emby from '@images/misc/emby.png'
|
||||
import jellyfin from '@images/misc/jellyfin.png'
|
||||
import trimemedia from '@images/logos/trimemedia.png'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -38,6 +39,7 @@ function getDefaultImage() {
|
||||
if (props.media?.server === 'plex') return plex
|
||||
else if (props.media?.server === 'emby') return emby
|
||||
else if (props.media?.server === 'jellyfin') return jellyfin
|
||||
else if (props.media?.server === 'trimemedia') return trimemedia
|
||||
else return plex
|
||||
}
|
||||
|
||||
@@ -91,7 +93,17 @@ async function drawImages(imageList: string[]) {
|
||||
const img = new Image()
|
||||
img.setAttribute('crossorigin', 'anonymous')
|
||||
img.src = imgSrc
|
||||
await new Promise(resolve => (img.onload = resolve))
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve()
|
||||
img.onerror = () => reject(new Error(`Failed to load image: ${imgSrc}`))
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
ctx.fillStyle = '#e5e7eb'
|
||||
ctx.fillRect(MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1), MARGIN_HEIGHT, POSTER_WIDTH, POSTER_HEIGHT)
|
||||
return
|
||||
}
|
||||
|
||||
const x = MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1)
|
||||
const y = MARGIN_HEIGHT
|
||||
@@ -146,7 +158,7 @@ onMounted(async () => {
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
|
||||
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
|
||||
}"
|
||||
@click="goPlay"
|
||||
>
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType, Ref } from 'vue'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
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, { registerAbortController } from '@/router'
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import tmdbImage from '@images/logos/tmdb.png'
|
||||
import doubanImage from '@images/logos/douban-black.png'
|
||||
import bangumiImage from '@images/logos/bangumi.png'
|
||||
import api from '@/api'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { formatSeason, formatRating } from '@/@core/utils/formatters'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { MediaInfo, Subscribe, MediaSeason, Site } from '@/api/types'
|
||||
import router, { registerAbortController } from '@/router'
|
||||
import { useUserStore } from '@/stores'
|
||||
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
|
||||
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
|
||||
import SubscribeSeasonDialog from '../dialog/SubscribeSeasonDialog.vue'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -22,7 +24,8 @@ const props = defineProps({
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettings: any = inject('globalSettings')
|
||||
|
||||
const store = useStore()
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
@@ -33,18 +36,12 @@ const isImageLoaded = ref(false)
|
||||
// 图片加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// TMDB识别标志
|
||||
const tmdbFlag = ref(true)
|
||||
|
||||
// 当前订阅状态
|
||||
const isSubscribed = ref(false)
|
||||
|
||||
// 本地存在状态
|
||||
const isExists = ref(false)
|
||||
|
||||
// 各季缺失状态:0-已入库 1-部分缺失 2-全部缺失,没有数据也是已入库
|
||||
const seasonsNotExisted = ref<{ [key: number]: number }>({})
|
||||
|
||||
// 订阅季弹窗
|
||||
const subscribeSeasonDialog = ref(false)
|
||||
|
||||
@@ -54,11 +51,8 @@ const subscribeEditDialog = ref(false)
|
||||
// 订阅ID
|
||||
const subscribeId = ref<number>()
|
||||
|
||||
// 季详情
|
||||
const seasonInfos = ref<TmdbSeason[]>([])
|
||||
|
||||
// 选中的订阅季
|
||||
const seasonsSelected = ref<TmdbSeason[]>([])
|
||||
const seasonsSelected = ref<MediaSeason[]>([])
|
||||
|
||||
// 来源角标字典
|
||||
const sourceIconDict: { [key: string]: any } = {
|
||||
@@ -73,19 +67,49 @@ const mediaCardRef = ref<HTMLElement | null>(null)
|
||||
// 创建Intersection Observer实例
|
||||
const observer = ref<IntersectionObserver | null>(null)
|
||||
|
||||
// 所有站点
|
||||
const allSites = ref<Site[]>([])
|
||||
|
||||
// 选中的站点
|
||||
const selectedSites = ref<number[]>([])
|
||||
|
||||
// 搜索菜单显示状态
|
||||
const searchMenuShow = ref(false)
|
||||
|
||||
// 选择站点对话框
|
||||
const chooseSiteDialog = ref(false)
|
||||
|
||||
// 选择的剧集组
|
||||
const episodeGroup = ref('')
|
||||
|
||||
// 查询所有站点
|
||||
async function querySites() {
|
||||
try {
|
||||
const data: Site[] = await api.get('site/')
|
||||
|
||||
// 过滤站点,只有启用的站点才显示
|
||||
allSites.value = data.filter(item => item.is_active)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询用户选中的站点
|
||||
async function querySelectedSites() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/IndexerSites')
|
||||
selectedSites.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
if (props.media?.tmdb_id) return `tmdb:${props.media?.tmdb_id}`
|
||||
else if (props.media?.douban_id) return `douban:${props.media?.douban_id}`
|
||||
else return `bangumi:${props.media?.bangumi_id}`
|
||||
}
|
||||
|
||||
// 订阅弹窗选择的多季
|
||||
function subscribeSeasons() {
|
||||
subscribeSeasonDialog.value = false
|
||||
seasonsSelected.value.forEach(season => {
|
||||
addSubscribe(season.season_number)
|
||||
})
|
||||
else if (props.media?.bangumi_id) return `bangumi:${props.media?.bangumi_id}`
|
||||
else return `${props.media?.mediaid_prefix}:${props.media?.media_id}`
|
||||
}
|
||||
|
||||
// 角标颜色
|
||||
@@ -97,31 +121,10 @@ function getChipColor(type: string) {
|
||||
|
||||
// 添加订阅处理
|
||||
async function handleAddSubscribe() {
|
||||
if (props.media?.type === '电视剧' && props.media?.tmdb_id) {
|
||||
// TMDB电视剧
|
||||
// 查询TMDB所有季信息
|
||||
await getMediaSeasons()
|
||||
if (!seasonInfos.value) {
|
||||
$toast.error(`${props.media?.title} 查询剧集信息失败!`)
|
||||
return
|
||||
}
|
||||
// 检查各季的缺失状态
|
||||
await checkSeasonsNotExists()
|
||||
if (!tmdbFlag.value) return
|
||||
|
||||
if (seasonInfos.value.length === 1) {
|
||||
// 添加订阅
|
||||
addSubscribe(1)
|
||||
} else {
|
||||
// 弹出季选择列表,支持多选
|
||||
seasonsSelected.value = []
|
||||
subscribeSeasonDialog.value = true
|
||||
}
|
||||
} else if (props.media?.type === '电视剧') {
|
||||
// 豆瓣电视剧,只会有一季
|
||||
const season = props.media?.season ?? 1
|
||||
// 添加订阅
|
||||
addSubscribe(season)
|
||||
if (props.media?.type === '电视剧') {
|
||||
// 弹出季选择列表,支持多选
|
||||
seasonsSelected.value = []
|
||||
subscribeSeasonDialog.value = true
|
||||
} else {
|
||||
// 电影
|
||||
addSubscribe()
|
||||
@@ -129,15 +132,12 @@ async function handleAddSubscribe() {
|
||||
}
|
||||
|
||||
// 调用API添加订阅,电视剧的话需要指定季
|
||||
async function addSubscribe(season = 0) {
|
||||
async function addSubscribe(season: number = 0, best_version: number = 0) {
|
||||
// 开始处理
|
||||
startNProgress()
|
||||
try {
|
||||
// 是否洗版
|
||||
let best_version = isExists.value ? 1 : 0
|
||||
if (season && props.media?.tmdb_id)
|
||||
// 全部存在时洗版
|
||||
best_version = !seasonsNotExisted.value[season] ? 1 : 0
|
||||
if (!best_version && props.media?.type == '电影') best_version = isExists.value ? 1 : 0
|
||||
// 请求API
|
||||
const result: { [key: string]: any } = await api.post('subscribe/', {
|
||||
name: props.media?.title,
|
||||
@@ -146,8 +146,10 @@ async function addSubscribe(season = 0) {
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
doubanid: props.media?.douban_id,
|
||||
bangumiid: props.media?.bangumi_id,
|
||||
mediaid: props.media?.media_id ? `${props.media?.mediaid_prefix}:${props.media?.media_id}` : '',
|
||||
season,
|
||||
best_version,
|
||||
episode_group: episodeGroup.value,
|
||||
})
|
||||
|
||||
// 订阅状态
|
||||
@@ -267,50 +269,15 @@ async function checkSubscribe(season = 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 检查所有季的缺失状态(数据库)
|
||||
async function checkSeasonsNotExists() {
|
||||
// 开始处理
|
||||
startNProgress()
|
||||
try {
|
||||
const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', props.media)
|
||||
if (result) {
|
||||
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
|
||||
seasonsNotExisted.value[item.season] = state
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
$toast.error(`${props.media?.title}无法识别TMDB媒体信息!`)
|
||||
tmdbFlag.value = false
|
||||
} finally {
|
||||
// 处理完成
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// 查询TMDB的所有季信息
|
||||
async function getMediaSeasons() {
|
||||
try {
|
||||
seasonInfos.value = await api.get(`tmdb/seasons/${props.media?.tmdb_id}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询订阅弹窗规则
|
||||
async function queryDefaultSubscribeConfig() {
|
||||
// 非管理员不显示
|
||||
if (!store.state.auth.superUser) return false
|
||||
if (!userStore.superUser) return false
|
||||
try {
|
||||
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)
|
||||
@@ -324,24 +291,18 @@ function handleSubscribe() {
|
||||
else handleAddSubscribe()
|
||||
}
|
||||
|
||||
// 计算存在状态的颜色
|
||||
function getExistColor(season: number) {
|
||||
const state = seasonsNotExisted.value[season]
|
||||
if (!state) 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 === 1) return '部分缺失'
|
||||
else if (state === 2) return '缺失'
|
||||
else return '已入库'
|
||||
// 订阅多季
|
||||
function subscribeSeasons(seasons: MediaSeason[], seasonNoExists: { [key: number]: number }, groudId: string) {
|
||||
subscribeSeasonDialog.value = false
|
||||
episodeGroup.value = groudId
|
||||
seasonsSelected.value = seasons || []
|
||||
seasonsSelected.value.forEach(season => {
|
||||
let best_version = 0
|
||||
if (season && props.media?.tmdb_id)
|
||||
// 全部存在时洗版
|
||||
best_version = !seasonNoExists[season.season_number || 0] ? 1 : 0
|
||||
addSubscribe(season.season_number, best_version)
|
||||
})
|
||||
}
|
||||
|
||||
// 打开详情页
|
||||
@@ -361,6 +322,8 @@ function goMediaDetail(isHovering = false) {
|
||||
path: '/media',
|
||||
query: {
|
||||
mediaid: getMediaId(),
|
||||
title: props.media?.title,
|
||||
year: props.media?.year,
|
||||
type: props.media?.type,
|
||||
},
|
||||
})
|
||||
@@ -368,6 +331,15 @@ function goMediaDetail(isHovering = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// 点击搜索
|
||||
async function clickSearch() {
|
||||
if (allSites.value?.length == 0) {
|
||||
querySites()
|
||||
querySelectedSites()
|
||||
}
|
||||
chooseSiteDialog.value = true
|
||||
}
|
||||
|
||||
// 开始搜索
|
||||
function handleSearch() {
|
||||
router.push({
|
||||
@@ -376,11 +348,21 @@ function handleSearch() {
|
||||
keyword: getMediaId(),
|
||||
type: props.media?.type,
|
||||
area: 'title',
|
||||
title: props.media?.title,
|
||||
year: props.media?.year,
|
||||
season: props.media?.season,
|
||||
sites: selectedSites.value.join(','),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 搜索多站点
|
||||
function searchSites(sites: number[]) {
|
||||
chooseSiteDialog.value = false
|
||||
selectedSites.value = sites
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
// 懒加载检查
|
||||
function handleCheckLazy() {
|
||||
if (props.media?.collection_id) {
|
||||
@@ -433,26 +415,6 @@ const getImgUrl: Ref<string> = computed(() => {
|
||||
return url
|
||||
})
|
||||
|
||||
// 拼装季图片地址
|
||||
function getSeasonPoster(posterPath: string) {
|
||||
if (!posterPath) return ''
|
||||
return `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w500${posterPath}`
|
||||
}
|
||||
|
||||
// 将yyyy-mm-dd转换为yyyy年mm月dd日
|
||||
function formatAirDate(airDate: string) {
|
||||
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.replaceAll(/-/g, '/'))
|
||||
return date.getFullYear()
|
||||
}
|
||||
|
||||
// 移除订阅
|
||||
function onRemoveSubscribe() {
|
||||
subscribeEditDialog.value = false
|
||||
@@ -467,9 +429,9 @@ function onRemoveSubscribe() {
|
||||
v-bind="hover.props"
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
class="outline-none shadow ring-gray-500 rounded-lg"
|
||||
class="outline-none shadow ring-gray-500 media-card"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
|
||||
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
@click.stop="goMediaDetail(hover.isHovering ?? false)"
|
||||
@@ -488,9 +450,10 @@ function onRemoveSubscribe() {
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
|
||||
<!-- 详情 -->
|
||||
<VCardText
|
||||
v-show="hover.isHovering || imageLoadError"
|
||||
v-show="hover.isHovering || imageLoadError || searchMenuShow"
|
||||
class="w-full h-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
|
||||
style="background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)"
|
||||
>
|
||||
@@ -503,7 +466,7 @@ function onRemoveSubscribe() {
|
||||
</p>
|
||||
<div v-if="props.media?.collection_id" class="mb-3" @click.stop=""></div>
|
||||
<div v-else class="flex align-center justify-between">
|
||||
<IconBtn icon="mdi-magnify" color="white" @click.stop="handleSearch" />
|
||||
<IconBtn icon="mdi-magnify" color="white" @click.stop="clickSearch" />
|
||||
<IconBtn icon="mdi-heart" :color="isSubscribed ? 'error' : 'white'" @click.stop="handleSubscribe" />
|
||||
</div>
|
||||
</VCardText>
|
||||
@@ -527,7 +490,7 @@ function onRemoveSubscribe() {
|
||||
:class="getChipColor('rating')"
|
||||
class="absolute right-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
|
||||
>
|
||||
{{ props.media?.vote_average }}
|
||||
{{ formatRating(props.media?.vote_average) }}
|
||||
</VChip>
|
||||
<!--来源图标-->
|
||||
<VAvatar
|
||||
@@ -535,7 +498,7 @@ function onRemoveSubscribe() {
|
||||
density="compact"
|
||||
class="absolute bottom-1 right-1"
|
||||
tile
|
||||
v-if="!hover.isHovering && isImageLoaded && props.media?.source"
|
||||
v-if="!hover.isHovering && isImageLoaded && props.media?.source && !imageLoadError"
|
||||
>
|
||||
<VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
|
||||
</VAvatar>
|
||||
@@ -544,62 +507,13 @@ function onRemoveSubscribe() {
|
||||
</template>
|
||||
</VHover>
|
||||
<!-- 订阅季弹窗 -->
|
||||
<VBottomSheet v-if="subscribeSeasonDialog" v-model="subscribeSeasonDialog" inset scrollable>
|
||||
<VCard class="rounded-t">
|
||||
<DialogCloseBtn @click="subscribeSeasonDialog = false" />
|
||||
<VCardItem>
|
||||
<VCardTitle class="pe-10"> 订阅 - {{ props.media?.title }} </VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<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"
|
||||
width="60"
|
||||
:src="getSeasonPoster(item.poster_path || '')"
|
||||
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> 第 {{ item.season_number }} 季 </VListItemTitle>
|
||||
<VListItemSubtitle class="mt-1 me-2">
|
||||
<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 }} 集
|
||||
</VListItemSubtitle>
|
||||
<VListItemSubtitle>
|
||||
《{{ 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)">
|
||||
{{ getExistText(item.season_number || 0) }}
|
||||
</VChip>
|
||||
</VListItemSubtitle>
|
||||
<template #append="{ isSelected }">
|
||||
<VListItemAction start>
|
||||
<VSwitch :model-value="isSelected" />
|
||||
</VListItemAction>
|
||||
</template>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
<div class="my-2 text-center">
|
||||
<VBtn :disabled="seasonsSelected.length === 0" width="30%" @click="subscribeSeasons">
|
||||
{{ seasonsSelected.length === 0 ? '请选择订阅季' : '提交订阅' }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCard>
|
||||
</VBottomSheet>
|
||||
<subscribeSeasonDialog
|
||||
v-if="subscribeSeasonDialog"
|
||||
v-model="subscribeSeasonDialog"
|
||||
:media="media"
|
||||
@subscribe="subscribeSeasons"
|
||||
@close="subscribeSeasonDialog = false"
|
||||
/>
|
||||
<!-- 订阅编辑弹窗 -->
|
||||
<SubscribeEditDialog
|
||||
v-if="subscribeEditDialog"
|
||||
@@ -609,4 +523,25 @@ function onRemoveSubscribe() {
|
||||
@save="subscribeEditDialog = false"
|
||||
@remove="onRemoveSubscribe"
|
||||
/>
|
||||
<!-- 站点选择对话框 -->
|
||||
<SearchSiteDialog
|
||||
v-if="chooseSiteDialog"
|
||||
v-model="chooseSiteDialog"
|
||||
:sites="allSites"
|
||||
:selected="selectedSites"
|
||||
@search="searchSites"
|
||||
@close="chooseSiteDialog = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.media-card {
|
||||
position: relative;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.03);
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,8 +4,9 @@ import { useToast } from 'vue-toast-notification'
|
||||
import emby_image from '@images/logos/emby.png'
|
||||
import jellyfin_image from '@images/logos/jellyfin.png'
|
||||
import plex_image from '@images/logos/plex.png'
|
||||
import trimemedia_image from '@images/logos/trimemedia.png'
|
||||
import api from '@/api'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
@@ -101,6 +102,8 @@ const getIcon = computed(() => {
|
||||
return emby_image
|
||||
case 'jellyfin':
|
||||
return jellyfin_image
|
||||
case 'trimemedia':
|
||||
return trimemedia_image
|
||||
default:
|
||||
return plex_image
|
||||
}
|
||||
@@ -185,7 +188,7 @@ onMounted(() => {
|
||||
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-model="mediaServerInfoDialog" scrollable max-width="40rem" persistent>
|
||||
<VDialog v-if="mediaServerInfoDialog" v-model="mediaServerInfoDialog" scrollable max-width="40rem" persistent>
|
||||
<VCard :title="`${props.mediaserver.name} - 配置`" class="rounded-t">
|
||||
<DialogCloseBtn v-model="mediaServerInfoDialog" />
|
||||
<VDivider />
|
||||
@@ -278,6 +281,53 @@ onMounted(() => {
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="mediaServerInfo.type == 'trimemedia'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.name"
|
||||
label="名称"
|
||||
placeholder="必填;不可与其他名称重名"
|
||||
hint="媒体服务器的别名"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.host"
|
||||
label="地址"
|
||||
placeholder="http(s)://ip:port"
|
||||
hint="服务端地址,格式:http(s)://ip:port"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
label="外网播放地址"
|
||||
placeholder="http(s)://domain:port"
|
||||
hint="跳转播放页面使用的地址,格式:http(s)://domain:port"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.username"
|
||||
label="用户名"
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
type="password"
|
||||
v-model="mediaServerInfo.config.password"
|
||||
label="密码"
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="mediaServerInfo.type == 'plex'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
|
||||
@@ -7,7 +7,7 @@ import synologychat_image from '@images/logos/synologychat.png'
|
||||
import slack_image from '@images/logos/slack.webp'
|
||||
import chrome_image from '@images/logos/chrome.png'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { cloneDeep } from "lodash"
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
@@ -132,7 +132,7 @@ function onClose() {
|
||||
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-model="notificationInfoDialog" scrollable max-width="40rem" persistent>
|
||||
<VDialog v-if="notificationInfoDialog" v-model="notificationInfoDialog" scrollable max-width="40rem" persistent>
|
||||
<VCard :title="`${props.notification.name} - 配置`" class="rounded-t">
|
||||
<DialogCloseBtn v-model="notificationInfoDialog" />
|
||||
<VDivider />
|
||||
|
||||
@@ -77,9 +77,8 @@ function goPersonDetail() {
|
||||
v-bind="hover.props"
|
||||
:height="personProps.height"
|
||||
:width="personProps.width"
|
||||
class="rounded-lg"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 scale-105': hover.isHovering,
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
@click.stop="goPersonDetail"
|
||||
>
|
||||
@@ -116,7 +115,7 @@ function goPersonDetail() {
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
<style lang="scss" scoped>
|
||||
.person-card {
|
||||
background-image: linear-gradient(45deg, rgb(var(--v-theme-background)), rgb(var(--v-theme-surface)) 60%);
|
||||
}
|
||||
|
||||
@@ -147,72 +147,87 @@ const dropdownItems = ref([
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VCard :width="props.width" :height="props.height" @click="detailDialog = true" class="flex flex-col h-full">
|
||||
<div
|
||||
class="relative flex flex-row items-start pa-3 justify-between grow"
|
||||
:style="{ background: `${backgroundColor}` }"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center"
|
||||
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.5)' }"
|
||||
></div>
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<VCardTitle class="text-white text-lg px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis ...">
|
||||
{{ props.plugin?.plugin_name }}
|
||||
<span class="text-sm text-gray-200">v{{ props.plugin?.plugin_version }}</span>
|
||||
</VCardTitle>
|
||||
<VCardText class="text-white text-sm px-2 py-0 text-shadow overflow-hidden line-clamp-3 ...">
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="detailDialog = true"
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="relative flex flex-row items-start pa-3 justify-between grow"
|
||||
:style="{ background: `${backgroundColor}` }"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center"
|
||||
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.5)' }"
|
||||
></div>
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<VCardTitle
|
||||
class="text-white text-lg px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis ..."
|
||||
>
|
||||
{{ props.plugin?.plugin_name }}
|
||||
<span class="text-sm text-gray-200">v{{ props.plugin?.plugin_version }}</span>
|
||||
</VCardTitle>
|
||||
<VCardText class="text-white text-sm px-2 py-0 text-shadow overflow-hidden line-clamp-3 ...">
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</VCardText>
|
||||
</div>
|
||||
<div class="relative flex-shrink-0 self-center">
|
||||
<VAvatar size="64">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
:class="{ shadow: isImageLoaded }"
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</div>
|
||||
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
|
||||
<span>
|
||||
<VIcon icon="mdi-github" class="me-1" />
|
||||
<a :href="props.plugin?.author_url" target="_blank" @click.stop>
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</a>
|
||||
</span>
|
||||
<span v-if="props.count" class="ms-3">
|
||||
<VIcon icon="mdi-download" />
|
||||
<span class="text-sm ms-1 mt-1">{{ props.count?.toLocaleString() }}</span>
|
||||
</span>
|
||||
<div class="me-n3 absolute bottom-1 right-3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(item, i) in dropdownItems"
|
||||
v-show="item.show"
|
||||
:key="i"
|
||||
variant="plain"
|
||||
@click="item.props.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
</div>
|
||||
<div class="relative flex-shrink-0 self-center">
|
||||
<VAvatar size="64">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
:class="{ shadow: isImageLoaded }"
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</div>
|
||||
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
|
||||
<span>
|
||||
<VIcon icon="mdi-github" class="me-1" />
|
||||
<a :href="props.plugin?.author_url" target="_blank" @click.stop>
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</a>
|
||||
</span>
|
||||
<span v-if="props.count" class="ms-3">
|
||||
<VIcon icon="mdi-download" />
|
||||
<span class="text-sm ms-1 mt-1">{{ props.count?.toLocaleString() }}</span>
|
||||
</span>
|
||||
<div class="me-n3 absolute bottom-1 right-3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(item, i) in dropdownItems"
|
||||
v-show="item.show"
|
||||
:key="i"
|
||||
variant="plain"
|
||||
@click="item.props.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</template>
|
||||
</VHover>
|
||||
<!-- 安装插件进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||
<!-- 更新日志 -->
|
||||
|
||||
@@ -3,19 +3,13 @@ import { useToast } from 'vue-toast-notification'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
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 { useDisplay } from 'vuetify'
|
||||
import VersionHistory from '@/components/misc/VersionHistory.vue'
|
||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
// APP
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
import PluginConfigDialog from '../dialog/PluginConfigDialog.vue'
|
||||
import PluginDataDialog from '../dialog/PluginDataDialog.vue'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -47,18 +41,12 @@ const isVisible = ref(true)
|
||||
// 插件配置页面
|
||||
const pluginConfigDialog = ref(false)
|
||||
|
||||
// 插件配置表单数据
|
||||
const pluginConfigForm = ref({})
|
||||
|
||||
// 菜单显示状态
|
||||
const menuVisible = ref(false)
|
||||
|
||||
// 进度框
|
||||
const progressDialog = ref(false)
|
||||
|
||||
// 插件表单配置项
|
||||
let pluginFormItems = reactive([])
|
||||
|
||||
// 插件数据页面
|
||||
const pluginInfoDialog = ref(false)
|
||||
|
||||
@@ -68,9 +56,6 @@ const progressText = ref('正在更新插件...')
|
||||
// 用户头像是否加载完成
|
||||
const isAvatarLoaded = ref(false)
|
||||
|
||||
// 插件数据页面配置项
|
||||
let pluginPageItems = ref([])
|
||||
|
||||
// 图片是否加载完成
|
||||
const isImageLoaded = ref(false)
|
||||
|
||||
@@ -138,75 +123,14 @@ async function uninstallPlugin() {
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API读取表单页面
|
||||
async function loadPluginForm() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(`plugin/form/${props.plugin?.id}`)
|
||||
if (result) {
|
||||
pluginFormItems = result.conf
|
||||
if (result.model) pluginConfigForm.value = result.model
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API读取数据页面
|
||||
async function loadPluginPage() {
|
||||
try {
|
||||
const result: [] = await api.get(`plugin/page/${props.plugin?.id}`)
|
||||
if (result) pluginPageItems.value = result
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API读取配置数据
|
||||
async function loadPluginConf() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(`plugin/${props.plugin?.id}`)
|
||||
if (!isNullOrEmptyObject(result)) pluginConfigForm.value = result
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API保存配置数据
|
||||
async function savePluginConf() {
|
||||
// 显示等待提示框
|
||||
progressDialog.value = true
|
||||
progressText.value = `正在保存 ${props.plugin?.plugin_name} 配置...`
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.put(`plugin/${props.plugin?.id}`, pluginConfigForm.value)
|
||||
if (result.success) {
|
||||
progressDialog.value = false
|
||||
pluginConfigDialog.value = false
|
||||
$toast.success(`插件 ${props.plugin?.plugin_name} 配置已保存`)
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
} else {
|
||||
progressDialog.value = false
|
||||
$toast.error(`插件 ${props.plugin?.plugin_name} 配置保存失败:${result.message}}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 显示插件数据
|
||||
async function showPluginInfo() {
|
||||
// 加载数据
|
||||
await loadPluginPage()
|
||||
pluginConfigDialog.value = false
|
||||
pluginInfoDialog.value = true
|
||||
}
|
||||
|
||||
// 显示插件配置
|
||||
async function showPluginConfig() {
|
||||
// 加载表单
|
||||
await loadPluginForm()
|
||||
// 加载配置
|
||||
await loadPluginConf()
|
||||
// 显示对话框
|
||||
pluginInfoDialog.value = false
|
||||
pluginConfigDialog.value = true
|
||||
@@ -303,6 +227,12 @@ function openPluginDetail() {
|
||||
else showPluginConfig()
|
||||
}
|
||||
|
||||
// 配置完成
|
||||
function configDone() {
|
||||
pluginConfigDialog.value = false
|
||||
emit('save')
|
||||
}
|
||||
|
||||
// 弹出菜单
|
||||
const dropdownItems = ref([
|
||||
{
|
||||
@@ -405,6 +335,9 @@ watch(
|
||||
:height="props.height"
|
||||
@click="openPluginDetail"
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="relative flex flex-row items-start pa-3 justify-between grow"
|
||||
@@ -485,42 +418,23 @@ watch(
|
||||
</VHover>
|
||||
|
||||
<!-- 插件配置页面 -->
|
||||
<VDialog v-model="pluginConfigDialog" scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard :title="`${props.plugin?.plugin_name} - 配置`" class="rounded-t">
|
||||
<DialogCloseBtn v-model="pluginConfigDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :model="pluginConfigForm" />
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn v-if="pluginPageItems.length > 0" @click="showPluginInfo" variant="outlined" color="info">
|
||||
查看数据
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn @click="savePluginConf" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 保存 </VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<PluginConfigDialog
|
||||
v-if="pluginConfigDialog"
|
||||
v-model="pluginConfigDialog"
|
||||
:plugin="props.plugin"
|
||||
@save="configDone"
|
||||
@close="pluginConfigDialog = false"
|
||||
@switch="showPluginInfo"
|
||||
/>
|
||||
|
||||
<!-- 插件数据页面 -->
|
||||
<VDialog v-model="pluginInfoDialog" scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard :title="`${props.plugin?.plugin_name}`" class="rounded-t">
|
||||
<DialogCloseBtn v-model="pluginInfoDialog" />
|
||||
<VCardText class="min-h-40">
|
||||
<PageRender @action="loadPluginPage" v-for="(item, index) in pluginPageItems" :key="index" :config="item" />
|
||||
</VCardText>
|
||||
<VFab
|
||||
icon="mdi-cog"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="showPluginConfig"
|
||||
:class="{ 'mb-10': appMode }"
|
||||
/>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<PluginDataDialog
|
||||
v-if="pluginInfoDialog"
|
||||
v-model="pluginInfoDialog"
|
||||
:plugin="props.plugin"
|
||||
@close="pluginInfoDialog = false"
|
||||
@switch="showPluginConfig"
|
||||
/>
|
||||
|
||||
<!-- 进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||
|
||||
@@ -43,12 +43,11 @@ function goPlay(isHovering: boolean | null = false) {
|
||||
v-bind="hover.props"
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
class="outline-none shadow ring-gray-500 rounded-lg"
|
||||
class="outline-none shadow ring-gray-500"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
|
||||
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
@click.stop="goPlay(hover.isHovering)"
|
||||
>
|
||||
<VImg
|
||||
aspect-ratio="2/3"
|
||||
@@ -79,7 +78,7 @@ function goPlay(isHovering: boolean | null = false) {
|
||||
v-show="hover.isHovering || imageLoadError"
|
||||
class="w-full h-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2 pb-5"
|
||||
style="background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)"
|
||||
@click.stop=""
|
||||
@click.stop="goPlay(hover.isHovering)"
|
||||
>
|
||||
<span class="font-bold">{{ props.media?.subtitle }}</span>
|
||||
<h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -142,12 +142,22 @@ async function editSubscribeDialog() {
|
||||
subscribeEditDialog.value = true
|
||||
}
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
|
||||
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
|
||||
else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}`
|
||||
else return props.media?.mediaid
|
||||
}
|
||||
|
||||
// 查看媒体详情
|
||||
async function viewMediaDetail() {
|
||||
router.push({
|
||||
path: '/media',
|
||||
query: {
|
||||
mediaid: `${props.media?.tmdbid ? `tmdb:${props.media?.tmdbid}` : `douban:${props.media?.doubanid}`}`,
|
||||
mediaid: getMediaId(),
|
||||
title: props.media?.name,
|
||||
year: props.media?.year,
|
||||
type: props.media?.type,
|
||||
},
|
||||
})
|
||||
@@ -285,14 +295,15 @@ function onSubscribeEditRemove() {
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col rounded-lg h-full"
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
|
||||
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
|
||||
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
|
||||
'opacity-70': subscribeState === 'S',
|
||||
}"
|
||||
min-height="170"
|
||||
@click="editSubscribeDialog"
|
||||
:ripple="false"
|
||||
>
|
||||
<div class="me-n3 absolute top-1 right-2">
|
||||
<IconBtn>
|
||||
@@ -330,7 +341,7 @@ function onSubscribeEditRemove() {
|
||||
<div>
|
||||
<VCardText class="flex items-center">
|
||||
<div class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md shadow-lg" v-if="imageLoaded">
|
||||
<VImg :src="posterUrl" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
|
||||
<VImg :src="posterUrl" aspect-ratio="2/3" cover>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
@@ -410,7 +421,7 @@ function onSubscribeEditRemove() {
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss">
|
||||
<style lang="scss" scoped>
|
||||
.subscribe-card-background {
|
||||
background-image: linear-gradient(90deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
|
||||
}
|
||||
|
||||
@@ -54,12 +54,20 @@ const posterUrl = computed(() => {
|
||||
return url
|
||||
})
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
|
||||
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
|
||||
}
|
||||
|
||||
// 查看媒体详情
|
||||
async function viewMediaDetail() {
|
||||
router.push({
|
||||
path: '/media',
|
||||
query: {
|
||||
mediaid: `${props.media?.tmdbid ? `tmdb:${props.media?.tmdbid}` : `douban:${props.media?.doubanid}`}`,
|
||||
mediaid: getMediaId(),
|
||||
title: props.media?.name,
|
||||
year: props.media?.year,
|
||||
type: props.media?.type,
|
||||
},
|
||||
})
|
||||
@@ -73,30 +81,28 @@ function showForkSubscribe() {
|
||||
// 完成复用订阅
|
||||
function finishForkSubscribe(subid: number) {
|
||||
subscribeId.value = subid
|
||||
forkSubscribeDialog.value=false
|
||||
forkSubscribeDialog.value = false
|
||||
subscribeEditDialog.value = true
|
||||
}
|
||||
|
||||
// 删除订阅分享时处理
|
||||
function doDelete() {
|
||||
forkSubscribeDialog.value=false
|
||||
forkSubscribeDialog.value = false
|
||||
// 通知父组件刷新
|
||||
emit('delete')
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="h-full">
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col rounded-lg"
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
|
||||
'transition transform-cpu duration-300 -translate-y-1 shadow-lg': hover.isHovering,
|
||||
}"
|
||||
min-height="170"
|
||||
@click="showForkSubscribe"
|
||||
@@ -111,8 +117,8 @@ function doDelete() {
|
||||
<div class="absolute inset-0 subscribe-card-background"></div>
|
||||
</VImg>
|
||||
</template>
|
||||
<div>
|
||||
<VCardText class="flex items-center pb-1">
|
||||
<div class="h-full flex flex-col">
|
||||
<VCardText class="flex items-center pb-1 grow">
|
||||
<div class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md shadow-lg" v-if="imageLoaded">
|
||||
<VImg :src="posterUrl" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
|
||||
<template #placeholder>
|
||||
@@ -171,7 +177,7 @@ function doDelete() {
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss">
|
||||
<style lang="scss" scoped>
|
||||
.subscribe-card-background {
|
||||
background-image: linear-gradient(90deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ const meta = ref(props.torrent?.meta_info)
|
||||
const downloadItem = ref(props.torrent)
|
||||
|
||||
// 站点图标
|
||||
const siteIcon = ref('')
|
||||
const siteIcons = ref<Record<number, string>>({})
|
||||
|
||||
// 存储是否已经下载过的记录
|
||||
const downloaded = ref<string[]>([])
|
||||
@@ -51,9 +51,10 @@ function addDownloadError(error: string) {
|
||||
}
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon() {
|
||||
async function getSiteIcon(site: number | undefined) {
|
||||
if (!site) return
|
||||
try {
|
||||
siteIcon.value = (await api.get(`site/icon/${torrent?.value?.site}`)).data.icon
|
||||
siteIcons.value[site] = (await api.get(`site/icon/${site}`)).data.icon
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -78,141 +79,191 @@ async function downloadTorrentFile() {
|
||||
window.open(torrent.value?.enclosure, '_blank')
|
||||
}
|
||||
|
||||
// 促销Chip类
|
||||
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
||||
if (downloadVolume === 0) return 'text-white bg-lime-500'
|
||||
else if (downloadVolume < 1) return 'text-white bg-green-500'
|
||||
else if (uploadVolume !== 1) return 'text-white bg-sky-500'
|
||||
else return 'text-white bg-gray-500'
|
||||
// 获取优惠类型样式
|
||||
function getPromotionClass(downloadVolumeFactor: number | undefined, uploadVolumeFactor: number | undefined) {
|
||||
if (!downloadVolumeFactor) return 'free-discount'
|
||||
if (downloadVolumeFactor === 0) return 'free-discount'
|
||||
else if (downloadVolumeFactor < 1) return 'percent-discount'
|
||||
else if (uploadVolumeFactor !== undefined && uploadVolumeFactor > 1) return 'upload-bonus'
|
||||
else return ''
|
||||
}
|
||||
|
||||
// 打开更多来源对话框
|
||||
async function openMoreTorrentsDialog() {
|
||||
props.more?.forEach(t => {
|
||||
return getSiteIcon(t.torrent_info?.site)
|
||||
})
|
||||
showMoreTorrents.value = true
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
onMounted(() => {
|
||||
getSiteIcon()
|
||||
getSiteIcon(props.torrent?.torrent_info?.site)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="h-full">
|
||||
<VCard
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'elevated'"
|
||||
:width="props.width || '100%'"
|
||||
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'"
|
||||
@click="handleAddDownload(props.torrent)"
|
||||
class="torrent-card h-full"
|
||||
:class="{ 'downloaded-card': downloaded.includes(torrent?.enclosure || '') }"
|
||||
>
|
||||
<template v-if="!showMoreTorrents" #image>
|
||||
<VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0">
|
||||
<VImg :src="siteIcon" />
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardItem class="py-1">
|
||||
<VCardTitle class="break-words overflow-visible whitespace-break-spaces">
|
||||
{{ media?.title ?? meta?.name }} {{ meta?.season_episode }}
|
||||
<span class="text-green-700 ms-2 text-sm">↑{{ torrent?.seeders }}</span>
|
||||
<span class="text-orange-700 ms-2 text-sm">↓{{ torrent?.peers }}</span>
|
||||
</VCardTitle>
|
||||
<template #append>
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem variant="plain" @click="openTorrentDetail()">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-information" />
|
||||
</template>
|
||||
<VListItemTitle>查看详情</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem
|
||||
v-if="props.torrent?.torrent_info?.enclosure?.startsWith('http')"
|
||||
variant="plain"
|
||||
@click="downloadTorrentFile()"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-download" />
|
||||
</template>
|
||||
<VListItemTitle>下载种子文件</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VCardText class="text-subtitle-2">
|
||||
{{ torrent?.title }}
|
||||
</VCardText>
|
||||
<VCardText>【{{ torrent?.site_name }}】{{ torrent?.description }}</VCardText>
|
||||
<VCardItem v-if="torrent?.labels" class="pb-3 pt-0 pe-12">
|
||||
<VChip v-if="torrent?.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
||||
H&R
|
||||
</VChip>
|
||||
<VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
||||
{{ torrent?.freedate_diff }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-for="(label, index) in torrent?.labels"
|
||||
:key="index"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.edition" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
|
||||
{{ meta?.edition }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.resource_pix" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
|
||||
{{ meta?.resource_pix }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.video_encode" variant="elevated" size="small" class="me-1 mb-1 text-white bg-orange-500">
|
||||
{{ meta?.video_encode }}
|
||||
</VChip>
|
||||
<VChip v-if="torrent?.size" variant="elevated" size="small" class="me-1 mb-1 text-white bg-yellow-500">
|
||||
{{ formatFileSize(torrent?.size) }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.resource_team" variant="elevated" size="small" class="me-1 mb-1 text-white bg-cyan-500">
|
||||
{{ meta?.resource_team }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
|
||||
:class="getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ torrent?.volume_factor }}
|
||||
</VChip>
|
||||
</VCardItem>
|
||||
<VCardActions>
|
||||
<VBtn v-if="props.more && props.more.length > 0" @click.stop="showMoreTorrents = !showMoreTorrents">
|
||||
<template #append>
|
||||
<VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'" />
|
||||
</template>
|
||||
更多来源
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
<VExpandTransition>
|
||||
<div v-show="showMoreTorrents">
|
||||
<VDivider />
|
||||
<VChipGroup class="p-3" column>
|
||||
<VChip v-for="(item, index) in props.more" :key="index" @click.stop="handleAddDownload(item)">
|
||||
<template #append>
|
||||
<VBadge color="primary" :content="`↑${item.torrent_info?.seeders}`" inline size="small" />
|
||||
<VBadge
|
||||
v-if="item.torrent_info?.downloadvolumefactor !== 1 || item.torrent_info?.uploadvolumefactor !== 1"
|
||||
:content="item.torrent_info?.volume_factor"
|
||||
inline
|
||||
size="small"
|
||||
/>
|
||||
</template>
|
||||
{{ item.torrent_info.site_name }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
<!-- 优惠标签 -->
|
||||
<div
|
||||
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
|
||||
class="discount-banner"
|
||||
:class="getPromotionClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
|
||||
>
|
||||
{{ torrent?.volume_factor }}
|
||||
</div>
|
||||
|
||||
<!-- 媒体标题 -->
|
||||
<div class="card-header">
|
||||
<div class="media-title-wrapper flex flex-row flex-wrap justify-start">
|
||||
<span class="media-title me-2">
|
||||
{{ media?.title ?? meta?.name }}
|
||||
</span>
|
||||
<span v-if="meta?.season_episode" class="season-tag">{{ meta?.season_episode }}</span>
|
||||
</div>
|
||||
</VExpandTransition>
|
||||
|
||||
<!-- 站点信息条 -->
|
||||
<div class="site-info">
|
||||
<div class="d-flex align-center">
|
||||
<img
|
||||
:alt="torrent?.site_name"
|
||||
v-if="siteIcons[torrent?.site || 0]"
|
||||
:src="siteIcons[torrent?.site || 0]"
|
||||
class="site-icon"
|
||||
/>
|
||||
<span v-else class="site-fallback">{{ torrent?.site_name?.substring(0, 1) }}</span>
|
||||
<span class="site-name">{{ torrent?.site_name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="seeder-peers">
|
||||
<span v-if="torrent?.seeders" class="seed-info">
|
||||
<VIcon size="small" color="success" icon="mdi-arrow-up"></VIcon>{{ torrent?.seeders }}
|
||||
</span>
|
||||
<span v-if="torrent?.peers" class="peer-info">
|
||||
<VIcon size="small" color="warning" icon="mdi-arrow-down"></VIcon>{{ torrent?.peers }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 种子内容 -->
|
||||
<div class="card-content">
|
||||
<!-- 种子标题 -->
|
||||
<div class="torrent-title" :title="torrent?.title">
|
||||
{{ torrent?.title }}
|
||||
</div>
|
||||
|
||||
<!-- 种子描述 -->
|
||||
<div
|
||||
v-if="meta?.subtitle || torrent?.description"
|
||||
class="torrent-desc grow"
|
||||
:title="meta?.subtitle || torrent?.description"
|
||||
>
|
||||
{{ meta?.subtitle || torrent?.description }}
|
||||
</div>
|
||||
|
||||
<!-- 资源标签区 -->
|
||||
<div class="tags-container">
|
||||
<div v-if="meta?.edition" class="resource-tag edition">{{ meta?.edition }}</div>
|
||||
<div v-if="meta?.resource_pix" class="resource-tag resolution">{{ meta?.resource_pix }}</div>
|
||||
<div v-if="meta?.video_encode" class="resource-tag codec">{{ meta?.video_encode }}</div>
|
||||
<div v-if="meta?.resource_team" class="resource-tag team">{{ meta?.resource_team }}</div>
|
||||
<div v-for="(label, index) in torrent?.labels" :key="index" class="resource-tag label">{{ label }}</div>
|
||||
<div v-if="torrent?.hit_and_run" class="resource-tag hr">H&R</div>
|
||||
<div v-if="torrent?.freedate_diff" class="resource-tag expire">{{ torrent?.freedate_diff }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 卡片底部信息 -->
|
||||
<div class="card-footer">
|
||||
<div class="more-sources-wrapper" v-if="props.more && props.more.length > 0">
|
||||
<div class="more-sources-toggle" @click.stop="openMoreTorrentsDialog">
|
||||
<VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'" size="small" class="me-1"></VIcon>
|
||||
<span>更多来源 ({{ props.more.length }})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VSpacer />
|
||||
|
||||
<!-- 体积和详情按钮并排 -->
|
||||
<div class="card-actions">
|
||||
<div v-if="torrent?.size" class="size-badge">
|
||||
{{ formatFileSize(torrent.size) }}
|
||||
</div>
|
||||
|
||||
<VBtn
|
||||
size="small"
|
||||
icon="mdi-information-outline"
|
||||
variant="text"
|
||||
color="primary"
|
||||
class="detail-btn"
|
||||
@click.stop="openTorrentDetail"
|
||||
></VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<!-- 更多来源对话框 - 改为独立对话框 -->
|
||||
<VDialog v-model="showMoreTorrents" max-width="380px" location="center">
|
||||
<VCard>
|
||||
<VCardTitle class="py-2 d-flex align-center">
|
||||
<span>其他来源</span>
|
||||
<VSpacer />
|
||||
<VBtn variant="text" size="small" icon="mdi-close" @click.stop="showMoreTorrents = false"></VBtn>
|
||||
</VCardTitle>
|
||||
|
||||
<VDivider />
|
||||
|
||||
<VCardText class="more-sources-content">
|
||||
<div
|
||||
v-for="(item, index) in props.more"
|
||||
:key="index"
|
||||
@click.stop="handleAddDownload(item)"
|
||||
class="more-source-item cursor-pointer"
|
||||
>
|
||||
<div class="source-site-info">
|
||||
<img
|
||||
:alt="item.torrent_info?.site_name"
|
||||
v-if="siteIcons[item.torrent_info?.site || 0]"
|
||||
:src="siteIcons[item.torrent_info?.site || 0]"
|
||||
class="source-site-icon"
|
||||
/>
|
||||
<span v-else class="source-site-fallback">{{ item.torrent_info?.site_name?.substring(0, 1) }}</span>
|
||||
<span class="source-site-name">{{ item.torrent_info.site_name }}</span>
|
||||
<span v-if="item.meta_info?.season_episode" class="season-tag source-season-tag">
|
||||
{{ item.meta_info.season_episode }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="item.torrent_info?.downloadvolumefactor !== 1 || item.torrent_info?.uploadvolumefactor !== 1"
|
||||
class="source-discount"
|
||||
:class="
|
||||
getPromotionClass(item.torrent_info?.downloadvolumefactor, item.torrent_info?.uploadvolumefactor)
|
||||
"
|
||||
>
|
||||
{{ item.torrent_info?.volume_factor }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="source-stats">
|
||||
<span class="source-size">{{ formatFileSize(item.torrent_info?.size) }}</span>
|
||||
<span class="source-seeders">
|
||||
<VIcon size="x-small" color="success" icon="mdi-arrow-up"></VIcon>
|
||||
{{ item.torrent_info?.seeders }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<AddDownloadDialog
|
||||
v-if="addDownloadDialog"
|
||||
v-model="addDownloadDialog"
|
||||
@@ -227,3 +278,383 @@ onMounted(() => {
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.torrent-card {
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
box-shadow: none;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.torrent-card:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
}
|
||||
|
||||
.discount-banner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
color: white;
|
||||
padding: 4px 10px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 0 0 0 12px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.free-discount {
|
||||
background-color: #4caf50;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.percent-discount {
|
||||
background-color: #ff5722;
|
||||
}
|
||||
|
||||
.upload-bonus {
|
||||
background-color: #9c27b0;
|
||||
}
|
||||
|
||||
.size-badge {
|
||||
background-color: rgba(var(--v-theme-primary), 0.9);
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
border-radius: 4px;
|
||||
margin-right: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 12px 16px 0;
|
||||
}
|
||||
|
||||
.media-title-wrapper {
|
||||
margin-bottom: 8px;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.media-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
color: rgba(var(--v-theme-on-surface), 0.87);
|
||||
}
|
||||
|
||||
.season-tag {
|
||||
font-size: 0.875rem;
|
||||
background-color: #5c6bc0;
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.site-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.site-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.site-fallback {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 8px;
|
||||
font-weight: 700;
|
||||
color: rgba(var(--v-theme-on-surface), 0.8);
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: rgba(var(--v-theme-on-surface), 0.85);
|
||||
}
|
||||
|
||||
.seeder-peers {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.seed-info {
|
||||
font-size: 0.95rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.peer-info {
|
||||
font-size: 0.95rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.torrent-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: rgba(var(--v-theme-on-surface), 0.87);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.torrent-desc {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tags-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.resource-tag {
|
||||
font-size: 0.8rem;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.edition {
|
||||
background-color: #f44336;
|
||||
}
|
||||
|
||||
.resolution {
|
||||
background-color: #e91e63;
|
||||
}
|
||||
|
||||
.codec {
|
||||
background-color: #ff9800;
|
||||
}
|
||||
|
||||
.team {
|
||||
background-color: #03a9f4;
|
||||
}
|
||||
|
||||
.expire {
|
||||
background-color: #9c27b0;
|
||||
}
|
||||
|
||||
.label {
|
||||
background-color: #3f51b5;
|
||||
}
|
||||
|
||||
.hr {
|
||||
background-color: #000000;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-top: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.more-sources-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.more-sources-toggle {
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.more-sources-toggle:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
.more-sources-content {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.more-source-item {
|
||||
padding: 8px 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: background-color 0.2s;
|
||||
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.05);
|
||||
}
|
||||
|
||||
.more-source-item:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||
}
|
||||
|
||||
.source-site-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.source-site-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.source-site-fallback {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 0.7rem;
|
||||
color: rgba(var(--v-theme-on-surface), 0.8);
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.source-site-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.source-season-tag {
|
||||
font-size: 0.75rem;
|
||||
padding: 1px 4px;
|
||||
margin-left: 4px;
|
||||
background-color: #5c6bc0;
|
||||
}
|
||||
|
||||
.source-discount {
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
margin-left: 6px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.source-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.source-size {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.source-seeders {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-btn {
|
||||
border-radius: 50%;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.downloaded-card {
|
||||
border: 2px solid #4caf50 !important;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.resource-tag {
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.full-text {
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.menu-activator {
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.break-words {
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.overflow-visible {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.whitespace-break-spaces {
|
||||
white-space: normal !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,9 +10,6 @@ const props = defineProps({
|
||||
torrent: Object as PropType<Context>,
|
||||
})
|
||||
|
||||
// 更多来源界面
|
||||
const showMoreTorrents = ref(false)
|
||||
|
||||
// 种子信息
|
||||
const torrent = ref(props.torrent?.torrent_info)
|
||||
|
||||
@@ -25,6 +22,10 @@ const meta = ref(props.torrent?.meta_info)
|
||||
// 站点图标
|
||||
const siteIcon = ref('')
|
||||
|
||||
// 站点图标加载状态
|
||||
const iconLoading = ref(false)
|
||||
const iconError = ref(false)
|
||||
|
||||
// 存储是否已经下载过的记录
|
||||
const downloaded = ref<string[]>([])
|
||||
|
||||
@@ -33,11 +34,35 @@ const addDownloadDialog = ref(false)
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon() {
|
||||
try {
|
||||
siteIcon.value = (await api.get(`site/icon/${torrent?.value?.site}`)).data.icon
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
if (!torrent?.value?.site || iconLoading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
iconLoading.value = true
|
||||
iconError.value = false
|
||||
|
||||
try {
|
||||
const response = await api.get(`site/icon/${torrent.value.site}`)
|
||||
if (response && response.data && response.data.icon) {
|
||||
siteIcon.value = response.data.icon
|
||||
} else {
|
||||
iconError.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load site icon:', error)
|
||||
iconError.value = true
|
||||
} finally {
|
||||
iconLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取优惠类型样式
|
||||
function getPromotionClass(downloadVolumeFactor: number | undefined, uploadVolumeFactor: number | undefined) {
|
||||
if (!downloadVolumeFactor) return 'free-discount'
|
||||
if (downloadVolumeFactor === 0) return 'free-discount'
|
||||
else if (downloadVolumeFactor < 1) return 'percent-discount'
|
||||
else if (uploadVolumeFactor !== undefined && uploadVolumeFactor > 1) return 'upload-bonus'
|
||||
else return ''
|
||||
}
|
||||
|
||||
// 询问并添加下载
|
||||
@@ -63,19 +88,6 @@ function openTorrentDetail() {
|
||||
window.open(torrent.value?.page_url, '_blank')
|
||||
}
|
||||
|
||||
// 下载种子文件
|
||||
async function downloadTorrentFile() {
|
||||
window.open(torrent.value?.enclosure, '_blank')
|
||||
}
|
||||
|
||||
// 促销Chip类
|
||||
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
||||
if (downloadVolume === 0) return 'text-white bg-lime-500'
|
||||
else if (downloadVolume < 1) return 'text-white bg-green-500'
|
||||
else if (uploadVolume !== 1) return 'text-white bg-sky-500'
|
||||
else return 'text-white bg-gray-500'
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
onMounted(() => {
|
||||
getSiteIcon()
|
||||
@@ -83,96 +95,89 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="list-item-wrapper">
|
||||
<VListItem
|
||||
:value="props.torrent?.torrent_info?.enclosure"
|
||||
class="torrent-item rounded"
|
||||
:class="{ 'downloaded-item': downloaded.includes(torrent?.enclosure || '') }"
|
||||
@click="handleAddDownload"
|
||||
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'"
|
||||
>
|
||||
<template v-if="!showMoreTorrents" #prepend>
|
||||
<VAvatar class="rounded" variant="flat" @click.stop="openTorrentDetail">
|
||||
<VImg :src="siteIcon" />
|
||||
</VAvatar>
|
||||
<template v-slot:prepend>
|
||||
<div class="site-wrapper">
|
||||
<img :alt="torrent?.site_name" v-if="siteIcon" :src="siteIcon" class="site-icon" />
|
||||
<span v-else class="site-fallback">{{ torrent?.site_name?.substring(0, 1) }}</span>
|
||||
<div class="site-name d-none d-sm-block">{{ torrent?.site_name }}</div>
|
||||
<span
|
||||
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
|
||||
class="free-tag"
|
||||
:class="getPromotionClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
|
||||
>
|
||||
{{ torrent?.volume_factor }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="break-words overflow-visible whitespace-break-spaces">
|
||||
{{ torrent?.title }}
|
||||
<span class="text-green-700 ms-2 text-sm">↑{{ torrent?.seeders }}</span>
|
||||
<span class="text-orange-700 ms-2 text-sm">↓{{ torrent?.peers }}</span>
|
||||
|
||||
<VListItemTitle class="item-content">
|
||||
<div class="item-header">
|
||||
<div class="media-info flex flex-row flex-wrap justify-start">
|
||||
<span class="media-title me-2">{{ media?.title ?? meta?.name }}</span>
|
||||
<span v-if="meta?.season_episode" class="season-tag">{{ meta?.season_episode }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="torrent-title" :title="torrent?.title">
|
||||
{{ torrent?.title }}
|
||||
</div>
|
||||
|
||||
<div class="torrent-description" :title="meta?.subtitle || torrent?.description || '暂无描述'">
|
||||
{{ meta?.subtitle || torrent?.description || '暂无描述' }}
|
||||
</div>
|
||||
|
||||
<div class="tags-container">
|
||||
<div v-if="meta?.edition" class="resource-tag edition">{{ meta?.edition }}</div>
|
||||
<div v-if="meta?.resource_pix" class="resource-tag resolution">{{ meta?.resource_pix }}</div>
|
||||
<div v-if="meta?.video_encode" class="resource-tag codec">{{ meta?.video_encode }}</div>
|
||||
<div v-if="meta?.resource_team" class="resource-tag team">{{ meta?.resource_team }}</div>
|
||||
<div v-for="(label, index) in torrent?.labels" :key="index" class="resource-tag label">{{ label }}</div>
|
||||
<div v-if="torrent?.hit_and_run" class="resource-tag hr">H&R</div>
|
||||
<div v-if="torrent?.freedate_diff" class="resource-tag expire">{{ torrent?.freedate_diff }}</div>
|
||||
</div>
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle> 【{{ torrent?.site_name }}】{{ torrent?.description }} </VListItemSubtitle>
|
||||
<div v-if="torrent?.labels" class="pt-2">
|
||||
<VChip v-if="torrent?.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
||||
H&R
|
||||
</VChip>
|
||||
<VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
||||
{{ torrent?.freedate_diff }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-for="(label, index) in torrent?.labels"
|
||||
:key="index"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.edition" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
|
||||
{{ meta?.edition }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.resource_pix" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
|
||||
{{ meta?.resource_pix }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.video_encode" variant="elevated" size="small" class="me-1 mb-1 text-white bg-orange-500">
|
||||
{{ meta?.video_encode }}
|
||||
</VChip>
|
||||
<VChip v-if="torrent?.size" variant="elevated" size="small" class="me-1 mb-1 text-white bg-yellow-500">
|
||||
{{ formatFileSize(torrent?.size) }}
|
||||
</VChip>
|
||||
<VChip v-if="meta?.resource_team" variant="elevated" size="small" class="me-1 mb-1 text-white bg-cyan-500">
|
||||
{{ meta?.resource_team }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
|
||||
:class="getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ torrent?.volume_factor }}
|
||||
</VChip>
|
||||
</div>
|
||||
<template #append>
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem variant="plain" @click="openTorrentDetail()">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-information" />
|
||||
</template>
|
||||
<VListItemTitle>查看详情</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem
|
||||
v-if="props.torrent?.torrent_info?.enclosure?.startsWith('http')"
|
||||
variant="plain"
|
||||
@click="downloadTorrentFile()"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-download" />
|
||||
</template>
|
||||
<VListItemTitle>下载种子文件</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
|
||||
<template v-slot:append>
|
||||
<div class="item-actions">
|
||||
<div class="torrent-stats">
|
||||
<span v-if="torrent?.seeders" class="seed-info">
|
||||
<VIcon size="small" color="success" icon="mdi-arrow-up"></VIcon>{{ torrent?.seeders }}
|
||||
</span>
|
||||
<span v-if="torrent?.peers" class="peer-info">
|
||||
<VIcon size="small" color="warning" icon="mdi-arrow-down"></VIcon>{{ torrent?.peers }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<div v-if="torrent?.size" class="size-badge">
|
||||
{{ formatFileSize(torrent.size) }}
|
||||
</div>
|
||||
|
||||
<VBtn
|
||||
density="comfortable"
|
||||
variant="text"
|
||||
color="primary"
|
||||
icon="mdi-information-outline"
|
||||
size="small"
|
||||
class="detail-btn"
|
||||
@click.stop="openTorrentDetail"
|
||||
></VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
|
||||
<AddDownloadDialog
|
||||
v-if="addDownloadDialog"
|
||||
v-model="addDownloadDialog"
|
||||
:title="`${media?.title_year || meta?.name} ${meta?.season_episode}`"
|
||||
:title="`${media?.title_year || meta?.name} ${meta?.season_episode || ''}`"
|
||||
:media="media"
|
||||
:torrent="torrent"
|
||||
@done="addDownloadSuccess"
|
||||
@@ -181,3 +186,296 @@ onMounted(() => {
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-item-wrapper {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.torrent-item {
|
||||
padding: 12px;
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
box-shadow: none;
|
||||
margin-block-end: 8px;
|
||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.torrent-item:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
background-color: rgba(var(--v-theme-primary), 0.04);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.site-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
min-inline-size: 100px;
|
||||
}
|
||||
|
||||
.site-icon {
|
||||
border-radius: 4px;
|
||||
block-size: 32px;
|
||||
inline-size: 32px;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
|
||||
.site-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.1);
|
||||
block-size: 24px;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
inline-size: 24px;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
color: rgba(var(--v-theme-on-surface), 0.8);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
|
||||
.season-tag {
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
background-color: #5c6bc0;
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-inline-end: 8px;
|
||||
padding-block: 2px;
|
||||
padding-inline: 6px;
|
||||
}
|
||||
|
||||
.free-tag {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: 0;
|
||||
padding-block: 2px;
|
||||
padding-inline: 6px;
|
||||
}
|
||||
|
||||
.free-discount {
|
||||
background-color: #4caf50;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.percent-discount {
|
||||
background-color: #ff5722;
|
||||
}
|
||||
|
||||
.upload-bonus {
|
||||
background-color: #9c27b0;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.media-info {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.media-title {
|
||||
color: rgba(var(--v-theme-on-surface), 0.87);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.torrent-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.seed-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.peer-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.size-badge {
|
||||
border-radius: 4px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.1);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin-inline-end: 6px;
|
||||
padding-block: 2px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.torrent-title {
|
||||
overflow: hidden;
|
||||
color: rgba(var(--v-theme-on-surface), 0.87);
|
||||
font-size: 0.9rem;
|
||||
margin-block-end: 6px;
|
||||
max-inline-size: 100%;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.torrent-description {
|
||||
overflow: hidden;
|
||||
color: rgba(var(--v-theme-on-surface), 0.65);
|
||||
font-size: 0.8rem;
|
||||
inline-size: 100%;
|
||||
margin-block-end: 8px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tags-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.resource-tag {
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
padding-block: 3px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.edition {
|
||||
background-color: #f44336;
|
||||
}
|
||||
|
||||
.resolution {
|
||||
background-color: #e91e63;
|
||||
}
|
||||
|
||||
.codec {
|
||||
background-color: #ff9800;
|
||||
}
|
||||
|
||||
.team {
|
||||
background-color: #03a9f4;
|
||||
}
|
||||
|
||||
.expire {
|
||||
background-color: #9c27b0;
|
||||
}
|
||||
|
||||
.label {
|
||||
background-color: #3f51b5;
|
||||
}
|
||||
|
||||
.hr {
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.detail-btn {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.downloaded-item {
|
||||
border-inline-start: 4px solid #4caf50;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.break-words {
|
||||
word-break: break-word;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.overflow-visible {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.whitespace-break-spaces {
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.torrent-item {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.site-icon,
|
||||
.site-fallback {
|
||||
block-size: 24px;
|
||||
inline-size: 24px;
|
||||
}
|
||||
|
||||
.site-wrapper {
|
||||
flex-wrap: wrap;
|
||||
margin-inline-end: 10px;
|
||||
min-inline-size: 24px;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
font-size: 0.8rem;
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
|
||||
.size-badge {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.resource-tag {
|
||||
font-size: 0.75rem;
|
||||
padding-block: 2px;
|
||||
padding-inline: 6px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.torrent-description {
|
||||
max-inline-size: calc(100vw - 150px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { Subscribe, User } from '@/api/types'
|
||||
import store from '@/store'
|
||||
import { useUserStore } from '@/stores'
|
||||
import avatar1 from '@images/avatars/avatar-1.png'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
import UserAddEditDialog from '@/components/dialog/UserAddEditDialog.vue'
|
||||
|
||||
// 扩展User类型以包含昵称字段
|
||||
interface ExtendedUser extends User {
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
// 定义输入变量
|
||||
const props = defineProps({
|
||||
// 用户信息
|
||||
user: {
|
||||
type: Object as PropType<User>,
|
||||
type: Object as PropType<ExtendedUser>,
|
||||
required: true,
|
||||
},
|
||||
// 所有用户
|
||||
@@ -22,10 +27,10 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
// 当前用户的ID
|
||||
const currentLoginUserId = computed(() => store.state.auth.userID)
|
||||
const currentLoginUserId = computed(() => useUserStore().userID)
|
||||
|
||||
// 当前用户是否是管理员
|
||||
const currentUserIsSuperuser = computed(() => store.state.auth.superUser)
|
||||
const currentUserIsSuperuser = computed(() => useUserStore().superUser)
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['remove', 'save'])
|
||||
@@ -45,6 +50,29 @@ const movieSubscriptions = ref(0)
|
||||
// 用户电视剧订阅数量
|
||||
const tvShowSubscriptions = ref(0)
|
||||
|
||||
// 是否显示更多操作菜单
|
||||
const showMenu = ref(false)
|
||||
|
||||
// 鼠标悬停状态
|
||||
const isHovered = ref(false)
|
||||
|
||||
// 是否为移动设备
|
||||
const isMobile = ref(window.innerWidth < 600)
|
||||
|
||||
// 显示名称 - 如果有昵称则优先显示昵称
|
||||
const displayName = computed(() => {
|
||||
const settingsNickname = props.user.settings?.nickname as string | undefined
|
||||
const nickname = props.user.nickname || settingsNickname
|
||||
return nickname || props.user.name
|
||||
})
|
||||
|
||||
// 计算用户卡片状态类
|
||||
const cardStatusClass = computed(() => {
|
||||
if (!props.user.is_active) return 'user-card-inactive'
|
||||
if (props.user.is_superuser) return 'user-card-admin'
|
||||
return ''
|
||||
})
|
||||
|
||||
// 按用户查询订阅数量
|
||||
async function fetchSubscriptions() {
|
||||
try {
|
||||
@@ -87,98 +115,157 @@ function editUser() {
|
||||
userEditDialog.value = true
|
||||
}
|
||||
|
||||
// 用户重新完成时
|
||||
// 用户更新完成时
|
||||
function onUserUpdate() {
|
||||
userEditDialog.value = false
|
||||
emit('save')
|
||||
}
|
||||
|
||||
// 更新窗口大小监听
|
||||
function handleResize() {
|
||||
isMobile.value = window.innerWidth < 600
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchSubscriptions()
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VCard>
|
||||
<VCardText class="text-center pt-10 pb-3">
|
||||
<VAvatar variant="flat" size="100" rounded>
|
||||
<VImg :src="user.avatar || avatar1" alt="avatar" />
|
||||
</VAvatar>
|
||||
<h5 class="text-h5 mt-3">{{ user.name }}</h5>
|
||||
<VChip size="small" class="mt-3" :class="{ 'text-error': user.is_superuser }">
|
||||
{{ user.is_superuser ? '管理员' : '普通用户' }}
|
||||
</VChip>
|
||||
</VCardText>
|
||||
<VCardText class="flex justify-center gap-6 pb-5">
|
||||
<div class="d-flex align-center">
|
||||
<VAvatar size="40" color="primary" rounded variant="tonal" class="me-4">
|
||||
<VIcon size="24" icon="mdi-movie-open-outline"></VIcon>
|
||||
<VCard
|
||||
class="user-card"
|
||||
:class="[{ 'user-card-hover': isHovered }, cardStatusClass, { 'mobile-card': isMobile }]"
|
||||
@mouseenter="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
>
|
||||
<!-- 管理员卡片装饰 -->
|
||||
<div v-if="user.is_superuser" class="admin-decoration">
|
||||
<div class="decoration-line"></div>
|
||||
<div class="decoration-circle"><VIcon icon="mdi-shield-star" size="x-small" color="warning" /></div>
|
||||
<div class="decoration-line"></div>
|
||||
</div>
|
||||
|
||||
<!-- 用户头像和基本信息 -->
|
||||
<div class="user-card-header" :class="{ 'admin-header': user.is_superuser }">
|
||||
<div class="user-avatar-container">
|
||||
<VAvatar
|
||||
:size="isMobile ? 50 : 74"
|
||||
rounded="lg"
|
||||
class="user-avatar"
|
||||
:class="{ 'admin-avatar': user.is_superuser, 'inactive-avatar': !user.is_active }"
|
||||
>
|
||||
<VImg :src="user.avatar || avatar1" :alt="user.name" />
|
||||
<div v-if="!user.is_active" class="avatar-overlay">
|
||||
<VIcon icon="mdi-account-lock" color="white" size="small" />
|
||||
</div>
|
||||
</VAvatar>
|
||||
<div>
|
||||
<div class="text-h6">{{ movieSubscriptions }}</div>
|
||||
<div class="text-sm text-no-wrap">电影订阅</div>
|
||||
<div v-if="user.is_superuser" class="admin-crown">
|
||||
<VIcon icon="mdi-crown" color="warning" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-center">
|
||||
<VAvatar size="40" color="primary" rounded variant="tonal" class="me-4">
|
||||
<VIcon size="24" icon="mdi-television"></VIcon>
|
||||
</VAvatar>
|
||||
<div>
|
||||
<div class="text-h6">{{ tvShowSubscriptions }}</div>
|
||||
<div class="text-sm text-no-wrap">电视剧订阅</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="pb-6">
|
||||
<VDivider class="my-2">
|
||||
<h5 class="text-h6">详情</h5>
|
||||
</VDivider>
|
||||
<VList lines="one">
|
||||
<VListItem>
|
||||
<VListItemTitle class="text-sm">
|
||||
<span class="font-weight-medium">邮箱:</span><span class="text-body-1"> {{ user.email }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem>
|
||||
<VListItemTitle class="text-sm">
|
||||
<span class="font-weight-medium">状态:</span
|
||||
><span class="text-body-1">
|
||||
<VChip size="small" :class="{ 'text-success': user.is_active }" variant="tonal">
|
||||
|
||||
<div class="user-info">
|
||||
<div class="user-name-section">
|
||||
<div class="name-and-badges">
|
||||
<h3 class="user-name" :class="{ 'admin-name': user.is_superuser, 'inactive-name': !user.is_active }">
|
||||
{{ displayName }}
|
||||
<VIcon
|
||||
v-if="user.nickname || user.settings?.nickname"
|
||||
icon="mdi-format-quote-close"
|
||||
size="x-small"
|
||||
color="info"
|
||||
class="nickname-icon"
|
||||
/>
|
||||
</h3>
|
||||
<div class="user-badges">
|
||||
<VChip v-if="user.is_superuser" size="x-small" color="error" class="user-badge admin-badge">管理员</VChip>
|
||||
<VChip v-else size="x-small" color="default" class="user-badge">普通用户</VChip>
|
||||
<VChip size="x-small" :color="user.is_active ? 'success' : 'grey'" variant="tonal" class="user-badge">
|
||||
{{ user.is_active ? '激活' : '已停用' }}
|
||||
</VChip>
|
||||
</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem>
|
||||
<VListItemTitle class="text-sm">
|
||||
<span class="font-weight-medium">双重认证:</span
|
||||
><span class="text-body-1">
|
||||
<VChip size="small" :class="{ 'text-success': user.is_otp }" variant="tonal">
|
||||
{{ user.is_otp ? '已启用' : '未启用' }}
|
||||
</VChip>
|
||||
</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
<VCardText class="flex flex-row justify-center">
|
||||
<VBtn
|
||||
v-if="currentUserIsSuperuser"
|
||||
color="primary"
|
||||
class="me-4"
|
||||
@click="editUser"
|
||||
>
|
||||
编辑
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="currentUserIsSuperuser && props.user.id != currentLoginUserId"
|
||||
color="error"
|
||||
variant="outlined"
|
||||
@click="removeUser"
|
||||
>
|
||||
删除
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
<VChip v-if="user.is_otp" size="x-small" color="info" variant="tonal" class="user-badge"> 2FA </VChip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 移动端订阅数据信息 -->
|
||||
<div v-if="isMobile" class="mobile-stats">
|
||||
<div class="mobile-stat-item">
|
||||
<VIcon size="x-small" icon="mdi-movie-outline" color="primary" />
|
||||
<span>{{ movieSubscriptions }}</span>
|
||||
</div>
|
||||
<div class="mobile-stat-item">
|
||||
<VIcon size="x-small" icon="mdi-television-classic" color="primary" />
|
||||
<span>{{ tvShowSubscriptions }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 头部操作按钮 -->
|
||||
<div class="user-actions" :class="{ 'mobile-actions': isMobile }">
|
||||
<VBtn
|
||||
icon
|
||||
size="small"
|
||||
:color="user.is_superuser ? 'warning' : 'primary'"
|
||||
variant="text"
|
||||
@click="editUser"
|
||||
class="action-btn"
|
||||
>
|
||||
<VIcon icon="mdi-pencil" />
|
||||
<VTooltip v-if="!isMobile" activator="parent" location="bottom">编辑用户</VTooltip>
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
v-if="props.user.id != currentLoginUserId && currentUserIsSuperuser"
|
||||
icon
|
||||
size="small"
|
||||
color="error"
|
||||
variant="text"
|
||||
@click="removeUser"
|
||||
class="action-btn"
|
||||
>
|
||||
<VIcon icon="mdi-delete" />
|
||||
<VTooltip v-if="!isMobile" activator="parent" location="bottom">删除用户</VTooltip>
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 独立的邮箱显示 -->
|
||||
<div class="email-container" :class="{ 'admin-email': user.is_superuser, 'inactive-email': !user.is_active }">
|
||||
<VIcon icon="mdi-email-outline" size="small" color="primary" class="email-icon" />
|
||||
<span class="email-text">{{ user.email || '未设置邮箱' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- PC端显示订阅统计信息 -->
|
||||
<div v-if="!isMobile" class="user-card-body">
|
||||
<div class="user-stats-container">
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon-container" :class="{ 'admin-stat': user.is_superuser }">
|
||||
<VIcon :color="user.is_superuser ? 'warning' : 'primary'" icon="mdi-movie-outline" size="20" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ movieSubscriptions }}</div>
|
||||
<div class="stat-label">电影订阅</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon-container" :class="{ 'admin-stat': user.is_superuser }">
|
||||
<VIcon :color="user.is_superuser ? 'warning' : 'primary'" icon="mdi-television-classic" size="20" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ tvShowSubscriptions }}</div>
|
||||
<div class="stat-label">剧集订阅</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<!-- 用户编辑弹窗 -->
|
||||
<UserAddEditDialog
|
||||
v-if="userEditDialog"
|
||||
@@ -190,3 +277,512 @@ onMounted(() => {
|
||||
@close="userEditDialog = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.user-card-hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.user-card-admin {
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box, border-box;
|
||||
background-image: linear-gradient(rgb(var(--v-theme-surface)), rgb(var(--v-theme-surface))),
|
||||
linear-gradient(120deg, rgba(var(--v-theme-warning), 0.5), rgba(var(--v-theme-error), 0.5));
|
||||
background-origin: border-box;
|
||||
}
|
||||
|
||||
.user-card-inactive {
|
||||
position: relative;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
background-color: rgba(var(--v-theme-surface), 0.95);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.user-card-inactive::before {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
backdrop-filter: grayscale(30%);
|
||||
content: '';
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.admin-decoration {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
inset-block-start: 0;
|
||||
inset-inline: 0;
|
||||
padding-block: 4px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.decoration-line {
|
||||
flex: 1;
|
||||
background: linear-gradient(90deg, rgba(var(--v-theme-warning), 0.1), rgba(var(--v-theme-warning), 0.7));
|
||||
block-size: 1px;
|
||||
}
|
||||
|
||||
.decoration-line:last-child {
|
||||
background: linear-gradient(90deg, rgba(var(--v-theme-warning), 0.7), rgba(var(--v-theme-warning), 0.1));
|
||||
}
|
||||
|
||||
.decoration-circle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(var(--v-theme-warning), 0.5);
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
block-size: 18px;
|
||||
inline-size: 18px;
|
||||
margin-block: 0;
|
||||
margin-inline: 8px;
|
||||
}
|
||||
|
||||
.user-card-header {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
padding-block: 20px 12px;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
background: linear-gradient(to bottom, rgba(var(--v-theme-warning), 0.05), transparent);
|
||||
}
|
||||
|
||||
.user-avatar-container {
|
||||
position: relative;
|
||||
margin-inline-end: 16px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
border: 4px solid rgb(var(--v-theme-surface));
|
||||
box-shadow: 0 4px 8px rgba(var(--v-theme-on-surface), 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.admin-avatar {
|
||||
border: 4px solid rgba(var(--v-theme-warning), 0.1);
|
||||
box-shadow: 0 5px 15px rgba(var(--v-theme-warning), 0.2);
|
||||
}
|
||||
|
||||
.admin-avatar::after {
|
||||
position: absolute;
|
||||
border: 1px solid rgba(var(--v-theme-warning), 0.3);
|
||||
border-radius: 12px;
|
||||
animation: pulse 2.5s infinite;
|
||||
content: '';
|
||||
inset: -5px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.6;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
70% {
|
||||
opacity: 0.2;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.inactive-avatar {
|
||||
border-color: rgba(var(--v-theme-on-surface), 0.1);
|
||||
filter: grayscale(50%);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.avatar-overlay {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(1px);
|
||||
background: rgba(var(--v-theme-on-surface), 0.2);
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.otp-badge {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: glow 2s infinite alternate;
|
||||
inset-block-end: 0;
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
.otp-badge .v-icon {
|
||||
color: #4caf50 !important;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 40%));
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
from {
|
||||
opacity: 0.9;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-otp {
|
||||
inset-block-end: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
}
|
||||
|
||||
.mobile-otp .v-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.admin-crown {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
background: transparent;
|
||||
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 40%));
|
||||
inset-block-start: -10px;
|
||||
inset-inline-start: -6px;
|
||||
transform: rotate(-25deg);
|
||||
}
|
||||
|
||||
.admin-crown .v-icon {
|
||||
color: #ffc107 !important;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0% {
|
||||
transform: rotate(-25deg) translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(-25deg) translateY(-3px);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(-25deg) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.nickname-icon {
|
||||
animation: pulse-nickname 2s ease infinite;
|
||||
filter: brightness(1.1);
|
||||
margin-inline-start: 4px;
|
||||
opacity: 0.9;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes pulse-nickname {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.9;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: move;
|
||||
margin-inline-end: 6px;
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.user-card:hover .drag-handle {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.user-name-section {
|
||||
margin-block-end: 8px;
|
||||
}
|
||||
|
||||
.name-and-badges {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-block-end: 4px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
margin-block: 0 4px;
|
||||
margin-inline: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-name {
|
||||
color: rgb(var(--v-theme-warning));
|
||||
font-weight: 700;
|
||||
text-shadow: 0 1px 2px rgba(var(--v-theme-warning), 0.1);
|
||||
}
|
||||
|
||||
.inactive-name {
|
||||
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||
}
|
||||
|
||||
.user-badges {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
margin-block-end: 4px;
|
||||
-ms-overflow-style: none;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.user-badges::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.7rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
border: 1px solid rgba(var(--v-theme-error), 0.3);
|
||||
}
|
||||
|
||||
.user-account,
|
||||
.user-email {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||
font-size: 0.8rem;
|
||||
inline-size: 100%;
|
||||
inset-block-start: 100%;
|
||||
inset-inline-start: 0;
|
||||
margin-block-start: 4px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account-label {
|
||||
color: rgba(var(--v-theme-on-surface), 0.5);
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
|
||||
.account-value {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
margin-inline-end: 4px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.email-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.mobile-actions {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
inset-block-start: 10px;
|
||||
inset-inline-end: 10px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
opacity: 0.7;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.mobile-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.mobile-stats {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
gap: 20px;
|
||||
margin-block-start: 8px;
|
||||
padding-block: 4px;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.mobile-stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.95rem;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mobile-stat-item .v-icon {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
|
||||
.mobile-stat-item span {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-card-body {
|
||||
padding-block: 0 16px;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.user-stats-container {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.02);
|
||||
margin-block-start: 8px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stat-icon-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.1);
|
||||
block-size: 40px;
|
||||
box-shadow: 0 2px 6px rgba(var(--v-theme-on-surface), 0.05);
|
||||
inline-size: 40px;
|
||||
}
|
||||
|
||||
.admin-stat {
|
||||
background-color: rgba(var(--v-theme-warning), 0.1);
|
||||
box-shadow: 0 2px 6px rgba(var(--v-theme-warning), 0.2);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: rgb(var(--v-theme-error));
|
||||
}
|
||||
|
||||
.email-container {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
background-color: transparent;
|
||||
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.05);
|
||||
padding-block: 8px;
|
||||
padding-inline: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-email {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.inactive-email {
|
||||
background-color: transparent;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.email-container .email-icon {
|
||||
flex-shrink: 0;
|
||||
margin-inline-end: 8px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.email-container .email-text {
|
||||
overflow: hidden;
|
||||
color: rgba(var(--v-theme-on-surface), 0.8);
|
||||
font-size: 0.9rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-card .email-container {
|
||||
padding-block: 6px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.mobile-card .email-container .email-text {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.mobile-card .user-avatar-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mobile-card .otp-badge {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
inset-block-end: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
327
src/components/cards/WorkflowTaskCard.vue
Normal file
327
src/components/cards/WorkflowTaskCard.vue
Normal file
@@ -0,0 +1,327 @@
|
||||
<script lang="ts" setup>
|
||||
import { Workflow } from '@/api/types'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue'
|
||||
import WorkflowActionsDialog from '@/components/dialog/WorkflowActionsDialog.vue'
|
||||
import api from '@/api'
|
||||
|
||||
// 定义输入参数
|
||||
const props = defineProps({
|
||||
workflow: {
|
||||
required: true,
|
||||
type: Object as PropType<Workflow>,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['refresh'])
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 编辑对话框
|
||||
const editDialog = ref(false)
|
||||
|
||||
// 流程对话框
|
||||
const flowDialog = ref(false)
|
||||
|
||||
// 加载中
|
||||
const loading = ref(false)
|
||||
|
||||
// 编辑任务
|
||||
function handleEdit(item: Workflow) {
|
||||
editDialog.value = true
|
||||
}
|
||||
|
||||
// 编辑流程
|
||||
function handleFlow(item: Workflow) {
|
||||
flowDialog.value = true
|
||||
}
|
||||
|
||||
// 计算已完成的动作数
|
||||
function resolveDoneActions(item: Workflow) {
|
||||
return item.current_action?.split(',').length || 0
|
||||
}
|
||||
|
||||
// 编辑完成
|
||||
function editDone() {
|
||||
editDialog.value = false
|
||||
flowDialog.value = false
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
// 删除任务
|
||||
async function handleDelete(item: Workflow) {
|
||||
const isConfirmed = await createConfirm({
|
||||
title: '确认',
|
||||
content: `是否确认删除任务 ${item.name} ?`,
|
||||
})
|
||||
|
||||
if (!isConfirmed) return
|
||||
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.delete(`workflow/${item.id}`)
|
||||
if (result.success) {
|
||||
$toast.success('删除任务成功!')
|
||||
emit('refresh')
|
||||
} else {
|
||||
$toast.error(`删除任务失败:${result.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 开始任务
|
||||
async function handleEnable(item: Workflow) {
|
||||
loading.value = true
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.post(`workflow/${item.id}/start`)
|
||||
if (result.success) {
|
||||
$toast.success('启用任务成功!')
|
||||
emit('refresh')
|
||||
} else {
|
||||
$toast.error(`启用任务失败:${result.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// 停用任务
|
||||
async function handlePause(item: Workflow) {
|
||||
loading.value = true
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.post(`workflow/${item.id}/pause`)
|
||||
if (result.success) {
|
||||
$toast.success('停用任务成功!')
|
||||
emit('refresh')
|
||||
} else {
|
||||
$toast.error(`停用任务失败:${result.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// 立即执行任务
|
||||
async function handleRun(item: Workflow, from_begin: boolean) {
|
||||
loading.value = true
|
||||
try {
|
||||
setTimeout(() => {
|
||||
emit('refresh')
|
||||
}, 500)
|
||||
const result: { [key: string]: string } = await api.post(`workflow/${item.id}/run?from_begin=${from_begin}`, {
|
||||
from_begin,
|
||||
})
|
||||
if (result.success) {
|
||||
$toast.success('任务执行完成!')
|
||||
emit('refresh')
|
||||
} else {
|
||||
$toast.error(`任务执行失败:${result.message}`)
|
||||
emit('refresh')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// 重置任务
|
||||
async function handleReset(item: Workflow) {
|
||||
const isConfirmed = await createConfirm({
|
||||
title: '确认',
|
||||
content: `是否确认重置任务 ${item.name} ?`,
|
||||
})
|
||||
|
||||
if (!isConfirmed) return
|
||||
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.post(`workflow/${item.id}/reset`)
|
||||
if (result.success) {
|
||||
$toast.success('重置任务成功!')
|
||||
emit('refresh')
|
||||
} else {
|
||||
$toast.error(`重置任务失败:${result.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算状态颜色
|
||||
const resolveStatusVariant = (status: string | undefined) => {
|
||||
if (status === 'S') return { color: 'success', text: '成功' }
|
||||
else if (status === 'R') return { color: 'primary', text: '运行中' }
|
||||
else if (status === 'F') return { color: 'error', text: '失败' }
|
||||
else if (status === 'P') return { color: 'secondary', text: '暂停' }
|
||||
else return { color: 'info', text: '等待' }
|
||||
}
|
||||
|
||||
// 计算当前动作占比
|
||||
const resolveProgress = (item: Workflow) => {
|
||||
const current_action_length = item.current_action?.split(',').length || 0
|
||||
return item.actions?.length ? Math.round((current_action_length / (item.actions.length || 1)) * 100) : 0
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<VHover v-slot="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
class="mx-auto h-full"
|
||||
@click="handleFlow(workflow)"
|
||||
:ripple="false"
|
||||
:loading="loading"
|
||||
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
|
||||
>
|
||||
<VCardItem class="py-3" :class="`bg-${resolveStatusVariant(workflow?.state).color}`">
|
||||
<template #prepend>
|
||||
<VAvatar variant="text" class="me-2">
|
||||
<VIcon
|
||||
v-if="workflow?.state === 'P'"
|
||||
color="success"
|
||||
size="x-large"
|
||||
icon="mdi-play"
|
||||
@click.stop="handleEnable(workflow)"
|
||||
/>
|
||||
<VIcon v-else color="warning" icon="mdi-pause" size="x-large" @click.stop="handlePause(workflow)" />
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle class="text-white">
|
||||
{{ workflow?.name }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle class="text-white">{{ workflow?.description }}</VCardSubtitle>
|
||||
<template #append>
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-vector-polyline-edit" @click.stop="handleFlow(workflow)" />
|
||||
</IconBtn>
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem variant="plain" base-color="primary" @click="handleEdit(workflow)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-note-edit" />
|
||||
</template>
|
||||
<VListItemTitle>编辑任务</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem
|
||||
v-if="workflow.current_action"
|
||||
variant="plain"
|
||||
base-color="info"
|
||||
@click="handleRun(workflow, false)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-play-speed" />
|
||||
</template>
|
||||
<VListItemTitle>继续执行</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem
|
||||
v-if="workflow.current_action"
|
||||
variant="plain"
|
||||
base-color="info"
|
||||
@click="handleRun(workflow, true)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-replay" />
|
||||
</template>
|
||||
<VListItemTitle>重新执行</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem v-else variant="plain" base-color="info" @click="handleRun(workflow, true)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-run" />
|
||||
</template>
|
||||
<VListItemTitle>立即执行</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem variant="plain" base-color="warning" @click="handleReset(workflow)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-restore-alert" />
|
||||
</template>
|
||||
<VListItemTitle>重置任务</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem variant="plain" base-color="error" @click="handleDelete(workflow)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-delete" />
|
||||
</template>
|
||||
<VListItemTitle>删除任务</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<div class="d-flex flex-column gap-y-4">
|
||||
<div class="d-flex flex-wrap gap-x-6">
|
||||
<div class="flex-1">
|
||||
<div class="mb-1">定时</div>
|
||||
<h5 class="text-h6">{{ workflow?.timer }}</h5>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="mb-1">状态</div>
|
||||
<h5 class="text-h6" :class="`text-${resolveStatusVariant(workflow?.state).color}`">
|
||||
{{ resolveStatusVariant(workflow?.state).text }}
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-x-6">
|
||||
<div class="flex-1">
|
||||
<div class="mb-1">动作数</div>
|
||||
<div>
|
||||
<VAvatar size="32" color="primary" variant="tonal">
|
||||
<span class="text-sm">{{ workflow?.actions?.length }}</span>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="mb-1">已执行次数</div>
|
||||
<h5 class="text-h6">{{ workflow?.run_count }}</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-x-6">
|
||||
<div class="flex-1">
|
||||
<div class="mb-1">进度</div>
|
||||
<div class="d-flex align-center gap-5">
|
||||
<div class="flex-grow-1">
|
||||
<VProgressLinear color="info" rounded :model-value="resolveProgress(workflow)" />
|
||||
</div>
|
||||
<span> {{ resolveProgress(workflow) }}% </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-x-6" v-if="workflow?.result">
|
||||
<div class="flex-1">
|
||||
<div class="mb-1">错误信息</div>
|
||||
<div class="text-error">{{ workflow?.result }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VHover>
|
||||
<!-- 流程对话框 -->
|
||||
<WorkflowActionsDialog
|
||||
v-if="flowDialog"
|
||||
v-model="flowDialog"
|
||||
@close="flowDialog = false"
|
||||
@save="editDone"
|
||||
:workflow="workflow"
|
||||
/>
|
||||
<!-- 编辑对话框 -->
|
||||
<WorkflowAddEditDialog
|
||||
v-if="editDialog"
|
||||
v-model="editDialog"
|
||||
@close="editDialog = false"
|
||||
@save="editDone"
|
||||
:workflow="workflow"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -118,77 +118,73 @@ onMounted(() => {
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VDialog max-width="45rem" scrollable>
|
||||
<VDialog max-width="35rem" scrollable>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle v-if="title">{{ torrent?.site_name }} - {{ title }}</VCardTitle>
|
||||
<VCardTitle v-else>确认下载</VCardTitle>
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
</VCardItem>
|
||||
<VCardTitle class="py-3 me-12">
|
||||
<VIcon icon="mdi-download" class="me-2" />
|
||||
<span v-if="title">{{ torrent?.site_name }} - {{ title }}</span>
|
||||
<span v-else>确认下载</span>
|
||||
</VCardTitle>
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VList lines="one">
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-web"></VIcon>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
<span class="whitespace-break-spaces me-2">{{ torrent?.title }}</span>
|
||||
<span class="text-green-700 ms-2 text-sm">↑{{ torrent?.seeders }}</span>
|
||||
<span class="text-orange-700 ms-2 text-sm">↓{{ torrent?.peers }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-subtitles-outline"></VIcon>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
<span class="text-body-1 whitespace-break-spaces">{{ torrent?.description }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-database"></VIcon>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
<span class="text-body-1">
|
||||
<VChip variant="tonal" label>
|
||||
{{ formatFileSize(torrent?.size || 0) }}
|
||||
</VChip>
|
||||
</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
<VRow>
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect
|
||||
v-model="selectedDownloader"
|
||||
:items="downloaderOptions"
|
||||
label="指定下载器"
|
||||
variant="underlined"
|
||||
placeholder="留空默认"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="8">
|
||||
<VCombobox
|
||||
v-model="selectedDirectory"
|
||||
:items="targetDirectories"
|
||||
label="指定保存目录"
|
||||
placeholder="留空自动匹配"
|
||||
variant="underlined"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VList lines="one">
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-web"></VIcon>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
<span class="whitespace-break-spaces me-2">{{ torrent?.title }}</span>
|
||||
<span class="text-green-700 ms-2 text-sm">↑{{ torrent?.seeders }}</span>
|
||||
<span class="text-orange-700 ms-2 text-sm">↓{{ torrent?.peers }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem v-if="torrent?.description">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-subtitles-outline"></VIcon>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
<span class="text-body-2 whitespace-break-spaces">{{ torrent?.description }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem v-if="torrent?.size">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-database"></VIcon>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
<span class="text-body-2">
|
||||
<VChip variant="tonal" label>
|
||||
{{ formatFileSize(torrent?.size || 0) }}
|
||||
</VChip>
|
||||
</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
<VRow class="px-7">
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect
|
||||
v-model="selectedDownloader"
|
||||
:items="downloaderOptions"
|
||||
size="small"
|
||||
label="下载器(默认)"
|
||||
variant="underlined"
|
||||
placeholder="留空默认"
|
||||
density="compact"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="8">
|
||||
<VCombobox
|
||||
v-model="selectedDirectory"
|
||||
:items="targetDirectories"
|
||||
label="保存目录(自动)"
|
||||
size="small"
|
||||
placeholder="留空自动匹配"
|
||||
variant="underlined"
|
||||
density="compact"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VCardText class="text-center">
|
||||
<VBtn
|
||||
variant="elevated"
|
||||
:disabled="loading"
|
||||
@click="addDownload"
|
||||
:prepend-icon="icon"
|
||||
class="px-5"
|
||||
size="large"
|
||||
>
|
||||
<VBtn variant="elevated" :disabled="loading" @click="addDownload" :prepend-icon="icon" class="px-5">
|
||||
{{ buttonText }}
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import api from '@/api'
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
conf: {
|
||||
type: Object as PropType<{ [key: string]: any }>,
|
||||
required: true,
|
||||
@@ -14,13 +13,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['done', 'close'])
|
||||
|
||||
// 二维码内容
|
||||
const qrCodeContent = ref('')
|
||||
|
||||
// ck参数
|
||||
const ck = ref('')
|
||||
|
||||
// t参数
|
||||
const t = ref('')
|
||||
const qrCodeUrl = ref('')
|
||||
|
||||
// 下方的提示信息
|
||||
const text = ref('请用阿里云盘 App 扫码')
|
||||
@@ -34,9 +27,6 @@ let timeoutTimer: NodeJS.Timeout | undefined = undefined
|
||||
// 完成
|
||||
async function handleDone() {
|
||||
clearTimeout(timeoutTimer)
|
||||
if (props.conf?.refreshToken) {
|
||||
await savaAliPanConfig()
|
||||
}
|
||||
emit('done')
|
||||
}
|
||||
|
||||
@@ -45,9 +35,8 @@ async function getQrcode() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('/storage/qrcode/alipan')
|
||||
if (result.success && result.data) {
|
||||
qrCodeContent.value = result.data.codeContent
|
||||
ck.value = result.data.ck
|
||||
t.value = result.data.t
|
||||
qrCodeUrl.value = result.data.codeUrl
|
||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||
} else {
|
||||
text.value = result.message
|
||||
}
|
||||
@@ -59,23 +48,21 @@ async function getQrcode() {
|
||||
// 调用/aliyun/check api验证二维码
|
||||
async function checkQrcode() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('/storage/check/alipan', {
|
||||
params: { ck: ck.value, t: t.value },
|
||||
})
|
||||
const result: { [key: string]: any } = await api.get('/storage/check/alipan')
|
||||
if (result.success && result.data) {
|
||||
const qrCodeStatus = result.data.qrCodeStatus
|
||||
const qrCodeStatus = result.data.status
|
||||
text.value = result.data.tip
|
||||
if (qrCodeStatus == 'CONFIRMED') {
|
||||
// 已确认完成
|
||||
if (qrCodeStatus == 'LoginSuccess') {
|
||||
// 登录成功
|
||||
alertType.value = 'success'
|
||||
handleDone()
|
||||
} else if (qrCodeStatus == 'NEW' || qrCodeStatus == 'SCANED') {
|
||||
} else if (qrCodeStatus == 'WaitLogin' || qrCodeStatus == 'ScanSuccess') {
|
||||
// 等待登录扫码成功
|
||||
alertType.value = 'info'
|
||||
// 新建、待扫码
|
||||
clearTimeout(timeoutTimer)
|
||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||
} else {
|
||||
// 过期或者已取消
|
||||
// 二维码过期
|
||||
alertType.value = 'error'
|
||||
}
|
||||
} else {
|
||||
@@ -87,18 +74,8 @@ async function checkQrcode() {
|
||||
}
|
||||
}
|
||||
|
||||
// 保存cookie设置
|
||||
async function savaAliPanConfig() {
|
||||
try {
|
||||
await api.post(`storage/save/alipan`, props.conf)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await getQrcode()
|
||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -112,19 +89,18 @@ onUnmounted(() => {
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VCardText class="pt-2 flex flex-col items-center">
|
||||
<div class="my-6 shadow-lg rounded text-center p-3 border">
|
||||
<QrcodeVue class="mx-auto" :value="qrCodeContent" :size="200" />
|
||||
<VImg class="mx-auto" :src="qrCodeUrl" width="200" height="200">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text">
|
||||
<template #prepend />
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol class="mt-2">
|
||||
<VTextField label="自定义refreshToken" v-model="props.conf.refreshToken" outlined dense />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>
|
||||
|
||||
@@ -26,6 +26,58 @@ const processing = ref(false)
|
||||
// 删除中
|
||||
const deleting = ref(false)
|
||||
|
||||
// 是否折叠
|
||||
const isExpanded = ref(false)
|
||||
|
||||
// follow用户列表
|
||||
const followUsers = ref<string[]>([])
|
||||
|
||||
// 当前用户是否已follow
|
||||
const isFollowed = computed(() => followUsers.value.includes(props.media?.share_uid || ''))
|
||||
|
||||
// 折叠展开
|
||||
function toggleExpand() {
|
||||
isExpanded.value = !isExpanded.value
|
||||
}
|
||||
|
||||
// 加载follow用户列表
|
||||
async function queryFollowUsers() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/FollowSubscribers')
|
||||
followUsers.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// follow用户
|
||||
async function followUser() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post(`subscribe/follow?share_uid=${props.media?.share_uid}`)
|
||||
if (result.success) {
|
||||
queryFollowUsers()
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// unfollow用户
|
||||
async function unfollowUser() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.delete('subscribe/follow', {
|
||||
params: {
|
||||
share_uid: props.media?.share_uid,
|
||||
},
|
||||
})
|
||||
if (result.success) {
|
||||
queryFollowUsers()
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算海报图片地址
|
||||
const posterUrl = computed(() => {
|
||||
const url = props.media?.poster
|
||||
@@ -35,12 +87,20 @@ const posterUrl = computed(() => {
|
||||
return url
|
||||
})
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
|
||||
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
|
||||
}
|
||||
|
||||
// 查看媒体详情
|
||||
async function viewMediaDetail() {
|
||||
router.push({
|
||||
path: '/media',
|
||||
query: {
|
||||
mediaid: `${props.media?.tmdbid ? `tmdb:${props.media?.tmdbid}` : `douban:${props.media?.doubanid}`}`,
|
||||
mediaid: getMediaId(),
|
||||
title: props.media?.name,
|
||||
year: props.media?.year,
|
||||
type: props.media?.type,
|
||||
},
|
||||
})
|
||||
@@ -97,9 +157,13 @@ async function doDelete() {
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
queryFollowUsers()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VDialog max-width="40rem">
|
||||
<VDialog max-width="40rem" scrollable>
|
||||
<VCard>
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VCardText>
|
||||
@@ -123,11 +187,13 @@ async function doDelete() {
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<VCardItem>
|
||||
<VCardTitle class="text-center text-md-left">
|
||||
<VCardTitle
|
||||
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-2 overflow-hidden text-ellipsis"
|
||||
>
|
||||
{{ props.media?.share_title }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle
|
||||
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-2 overflow-hidden text-ellipsis ..."
|
||||
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis"
|
||||
>
|
||||
{{ props.media?.share_comment }}
|
||||
</VCardSubtitle>
|
||||
@@ -144,9 +210,12 @@ async function doDelete() {
|
||||
<span class="text-body-1"> {{ media?.keyword }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem class="ps-0" v-if="media?.custom_words">
|
||||
<VListItem class="ps-0" v-if="media?.custom_words" @click.stop="toggleExpand">
|
||||
<VListItemTitle
|
||||
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-10 overflow-hidden text-ellipsis ..."
|
||||
class="text-center text-md-left break-words whitespace-break-spaces"
|
||||
:class="{
|
||||
'line-clamp-4 overflow-hidden text-ellipsis': !isExpanded,
|
||||
}"
|
||||
>
|
||||
<span class="font-weight-medium">识别词:</span>
|
||||
<span class="text-body-1"> {{ media?.custom_words }}</span>
|
||||
@@ -162,7 +231,7 @@ async function doDelete() {
|
||||
prepend-icon="mdi-heart"
|
||||
:loading="processing"
|
||||
>
|
||||
添加到我的订阅
|
||||
订阅
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="props.media?.share_uid && props.media?.share_uid === globalSettings.USER_UNIQUE_ID"
|
||||
@@ -175,6 +244,24 @@ async function doDelete() {
|
||||
>
|
||||
取消分享
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-else-if="isFollowed && props.media?.share_uid"
|
||||
color="warning"
|
||||
@click="unfollowUser"
|
||||
prepend-icon="mdi-account-remove"
|
||||
class="ms-2"
|
||||
>
|
||||
取消关注
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-else-if="props.media?.share_uid"
|
||||
@click="followUser"
|
||||
color="info"
|
||||
prepend-icon="mdi-account-plus"
|
||||
class="ms-2"
|
||||
>
|
||||
关注
|
||||
</VBtn>
|
||||
</div>
|
||||
<div class="text-xs mt-2" v-if="props.media?.count">
|
||||
<VIcon icon="mdi-fire" />共 {{ props.media?.count?.toLocaleString() }} 次复用
|
||||
|
||||
109
src/components/dialog/PluginConfigDialog.vue
Normal file
109
src/components/dialog/PluginConfigDialog.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import api from '@/api'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import FormRender from '../render/FormRender.vue'
|
||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
plugin: {
|
||||
type: Object as PropType<Plugin>,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close', 'save', 'switch'])
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 插件配置表单数据
|
||||
const pluginConfigForm = ref({})
|
||||
|
||||
// 插件表单配置项
|
||||
let pluginFormItems = reactive([])
|
||||
|
||||
// 进度框
|
||||
const progressDialog = ref(false)
|
||||
|
||||
// 进度文字
|
||||
const progressText = ref('')
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 是否刷新
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
// 调用API读取表单页面
|
||||
async function loadPluginForm() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(`plugin/form/${props.plugin?.id}`)
|
||||
if (result) {
|
||||
pluginFormItems = result.conf
|
||||
if (result.model) pluginConfigForm.value = result.model
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
isRefreshed.value = true
|
||||
}
|
||||
|
||||
// 调用API读取配置数据
|
||||
async function loadPluginConf() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(`plugin/${props.plugin?.id}`)
|
||||
if (!isNullOrEmptyObject(result)) pluginConfigForm.value = result
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
isRefreshed.value = true
|
||||
}
|
||||
|
||||
// 调用API保存配置数据
|
||||
async function savePluginConf() {
|
||||
// 显示等待提示框
|
||||
progressDialog.value = true
|
||||
progressText.value = `正在保存 ${props.plugin?.plugin_name} 配置...`
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.put(`plugin/${props.plugin?.id}`, pluginConfigForm.value)
|
||||
if (result.success) {
|
||||
progressDialog.value = false
|
||||
$toast.success(`插件 ${props.plugin?.plugin_name} 配置已保存`)
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
} else {
|
||||
progressDialog.value = false
|
||||
$toast.error(`插件 ${props.plugin?.plugin_name} 配置保存失败:${result.message}}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeMount(async () => {
|
||||
await loadPluginForm()
|
||||
await loadPluginConf()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VDialog scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard :title="`${props.plugin?.plugin_name} - 配置`" class="rounded-t">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
<VCardText v-if="isRefreshed">
|
||||
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :model="pluginConfigForm" />
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn v-if="props.plugin?.has_page" @click="emit('switch')" variant="outlined" color="info"> 查看数据 </VBtn>
|
||||
<VSpacer />
|
||||
<VBtn @click="savePluginConf" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 保存 </VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
<!-- 进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||
</VDialog>
|
||||
</template>
|
||||
63
src/components/dialog/PluginDataDialog.vue
Normal file
63
src/components/dialog/PluginDataDialog.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import PageRender from '@/components/render/PageRender.vue'
|
||||
import api from '@/api'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
plugin: {
|
||||
type: Object as PropType<Plugin>,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close', 'save', 'switch'])
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
// APP
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
|
||||
// 是否刷新
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
// 插件数据页面配置项
|
||||
let pluginPageItems = ref([])
|
||||
|
||||
// 调用API读取数据页面
|
||||
async function loadPluginPage() {
|
||||
try {
|
||||
const result: [] = await api.get(`plugin/page/${props.plugin?.id}`)
|
||||
if (result) pluginPageItems.value = result
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
isRefreshed.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPluginPage()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard :title="`${props.plugin?.plugin_name}`" class="rounded-t">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
|
||||
<VCardText v-else class="min-h-40">
|
||||
<PageRender @action="loadPluginPage" v-for="(item, index) in pluginPageItems" :key="index" :config="item" />
|
||||
</VCardText>
|
||||
<VFab
|
||||
icon="mdi-cog"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="emit('switch')"
|
||||
:class="{ 'mb-10': appMode }"
|
||||
/>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -7,7 +7,7 @@ const props = defineProps({
|
||||
<template>
|
||||
<!-- 手动整理进度框 -->
|
||||
<VDialog :scrim="false" width="25rem">
|
||||
<VCard color="primary">
|
||||
<VCard color="primary" rounded="md">
|
||||
<VCardText class="text-center">
|
||||
{{ props.text }}
|
||||
<VProgressLinear color="white" class="mb-0 mt-1" :model-value="props.value" indeterminate />
|
||||
|
||||
@@ -87,6 +87,7 @@ const transferForm = reactive<TransferForm>({
|
||||
|
||||
// 所有媒体库目录
|
||||
const directories = ref<TransferDirectoryConf[]>([])
|
||||
|
||||
// 查询目录
|
||||
async function loadDirectories() {
|
||||
try {
|
||||
@@ -221,7 +222,7 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VDialog scrollable max-width="45rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard :title="dialogTitle" class="rounded-t">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
@@ -263,7 +264,7 @@ onUnmounted(() => {
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="4">
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="transferForm.type_name"
|
||||
label="类型"
|
||||
@@ -276,7 +277,7 @@ onUnmounted(() => {
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="transferForm.tmdbid"
|
||||
@@ -302,19 +303,37 @@ onUnmounted(() => {
|
||||
@click:append-inner="mediaSelectorDialog = true"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect
|
||||
v-show="transferForm.type_name === '电视剧'"
|
||||
v-model.number="transferForm.season"
|
||||
label="季"
|
||||
:items="seasonItems"
|
||||
hint="指定季数"
|
||||
</VRow>
|
||||
<VRow v-show="transferForm.type_name === '电视剧'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="transferForm.episode_group"
|
||||
label="剧集组编号"
|
||||
placeholder="手动查询剧集组"
|
||||
hint="指定剧集组"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="8">
|
||||
<VCol cols="12" md="3">
|
||||
<VSelect
|
||||
v-model.number="transferForm.season"
|
||||
label="季"
|
||||
:items="seasonItems"
|
||||
hint="第几季"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="3">
|
||||
<VTextField
|
||||
v-model="transferForm.episode_detail"
|
||||
:disabled="disableEpisodeDetail"
|
||||
label="集"
|
||||
placeholder="起始集,终止集"
|
||||
hint="集数或范围,如1或1,2"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="transferForm.episode_format"
|
||||
label="集数定位"
|
||||
@@ -323,26 +342,7 @@ onUnmounted(() => {
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="transferForm.episode_detail"
|
||||
:disabled="disableEpisodeDetail"
|
||||
label="指定集数"
|
||||
placeholder="起始集,终止集,如1或1,2"
|
||||
hint="指定集数或范围,如1或1,2"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="transferForm.episode_part"
|
||||
label="指定Part"
|
||||
placeholder="如part1"
|
||||
hint="指定Part,如part1"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="transferForm.episode_offset"
|
||||
label="集数偏移"
|
||||
@@ -351,7 +351,18 @@ onUnmounted(() => {
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="transferForm.episode_part"
|
||||
label="指定Part"
|
||||
placeholder="如part1"
|
||||
hint="指定Part,如part1"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model.number="transferForm.min_filesize"
|
||||
label="最小文件大小(MB)"
|
||||
|
||||
926
src/components/dialog/SearchBarDialog.vue
Normal file
926
src/components/dialog/SearchBarDialog.vue
Normal file
@@ -0,0 +1,926 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import type { Site, Plugin, Subscribe } from '@/api/types'
|
||||
import { SystemNavMenus, SettingTabs } from '@/router/menu'
|
||||
import { NavMenu } from '@/@layouts/types'
|
||||
import { useUserStore } from '@/stores'
|
||||
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
|
||||
|
||||
// 定义props,接收modelValue
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
// 路由
|
||||
const router = useRouter()
|
||||
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 超级用户
|
||||
const superUser = userStore.superUser
|
||||
|
||||
// 当前用户名
|
||||
const userName = userStore.userName
|
||||
|
||||
// 所有订阅数据
|
||||
const SubscribeItems = ref<Subscribe[]>([])
|
||||
|
||||
// 站点选择对话框
|
||||
const chooseSiteDialog = ref(false)
|
||||
const selectedSites = ref<number[]>([])
|
||||
const allSites = ref<Site[]>([])
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close', 'update:modelValue'])
|
||||
|
||||
// 对话框状态的本地计算属性
|
||||
const dialog = computed({
|
||||
get: () => props.modelValue,
|
||||
set: val => emit('update:modelValue', val),
|
||||
})
|
||||
|
||||
// 搜索词
|
||||
const searchWord = ref<string | null>(null)
|
||||
|
||||
// ref
|
||||
const searchWordInput = ref<HTMLElement | null>(null)
|
||||
|
||||
// 近期搜索词条
|
||||
const recentSearches = ref<string[]>([])
|
||||
|
||||
// 保存近期搜索到本地
|
||||
function saveRecentSearches(keyword: string) {
|
||||
if (!keyword) return
|
||||
if (recentSearches.value.includes(keyword)) return
|
||||
recentSearches.value.unshift(keyword)
|
||||
localStorage.setItem('MP_RecentSearches', JSON.stringify(recentSearches.value))
|
||||
}
|
||||
|
||||
// 从本地加载近期搜索
|
||||
function loadRecentSearches() {
|
||||
const recentSearchesStr = localStorage.getItem('MP_RecentSearches')
|
||||
if (recentSearchesStr) {
|
||||
recentSearches.value = JSON.parse(recentSearchesStr)
|
||||
// 只保留最近的 5 条
|
||||
if (recentSearches.value.length > 5) {
|
||||
recentSearches.value = recentSearches.value.slice(0, 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 所有菜单功能
|
||||
function getMenus(): NavMenu[] {
|
||||
let menus: NavMenu[] = []
|
||||
// 导航菜单
|
||||
SystemNavMenus.forEach(
|
||||
item =>
|
||||
item &&
|
||||
menus.push({
|
||||
title: item.full_title ?? item.title,
|
||||
icon: item.icon,
|
||||
to: item.to,
|
||||
header: item.header,
|
||||
admin: item.admin,
|
||||
}),
|
||||
)
|
||||
// 设置标签页
|
||||
SettingTabs.forEach(
|
||||
item =>
|
||||
item &&
|
||||
menus.push({
|
||||
title: '设定 -> ' + item.title,
|
||||
icon: item.icon,
|
||||
to: `/setting?tab=${item.tab}`,
|
||||
header: '',
|
||||
admin: true,
|
||||
description: item.description,
|
||||
}),
|
||||
)
|
||||
|
||||
return menus
|
||||
}
|
||||
|
||||
// 匹配的菜单列表
|
||||
const matchedMenuItems = computed(() => {
|
||||
if (!searchWord.value) return []
|
||||
if (!superUser) return []
|
||||
const lowerWord = (searchWord.value as string).toLowerCase()
|
||||
const menuItems = getMenus()
|
||||
if (menuItems)
|
||||
return menuItems.filter(
|
||||
item =>
|
||||
item.title.toLowerCase().includes(lowerWord) ||
|
||||
(item.description && item.description.toLowerCase().includes(lowerWord)),
|
||||
)
|
||||
return []
|
||||
})
|
||||
|
||||
// 所有插件(已安装)
|
||||
const pluginItems = ref<Plugin[]>([])
|
||||
|
||||
// 获取插件列表数据
|
||||
async function fetchInstalledPlugins() {
|
||||
try {
|
||||
pluginItems.value = await api.get('plugin/', {
|
||||
params: {
|
||||
state: 'installed',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 区配的插件列表
|
||||
const matchedPluginItems = computed(() => {
|
||||
if (!searchWord.value) return []
|
||||
if (!superUser) return []
|
||||
const lowerWord = (searchWord.value as string).toLowerCase()
|
||||
return pluginItems.value.filter((item: Plugin) => {
|
||||
if (!item.plugin_name && !item.plugin_desc) return false
|
||||
return item.plugin_name?.toLowerCase().includes(lowerWord) || item.plugin_desc?.toLowerCase().includes(lowerWord)
|
||||
})
|
||||
})
|
||||
|
||||
// 获取订阅列表数据
|
||||
async function fetchSubscribes() {
|
||||
try {
|
||||
SubscribeItems.value = await api.get('subscribe/')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存用户站点选择到本地
|
||||
const saveUserSitePreferences = () => {
|
||||
try {
|
||||
localStorage.setItem('MP_SelectedSites', JSON.stringify(selectedSites.value))
|
||||
} catch (err) {
|
||||
console.error('保存站点选择失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 从本地或接口加载用户站点偏好设置
|
||||
const loadUserSitePreferences = async () => {
|
||||
try {
|
||||
// 先尝试从本地存储获取
|
||||
const storedSites = localStorage.getItem('MP_SelectedSites')
|
||||
if (storedSites) {
|
||||
selectedSites.value = JSON.parse(storedSites)
|
||||
console.log('从本地加载站点选择:', selectedSites.value)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果本地没有,尝试从接口获取系统预设
|
||||
const result = await api.get('system/setting/IndexerSites')
|
||||
if (result && result.data && result.data.value) {
|
||||
selectedSites.value = result.data.value
|
||||
console.log('从系统预设加载站点选择:', selectedSites.value)
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载站点选择失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取站点分类信息
|
||||
const getSiteCategories = () => {
|
||||
api
|
||||
.get('site/')
|
||||
.then(async (res: any) => {
|
||||
if (res && Array.isArray(res)) {
|
||||
allSites.value = res.filter((site: any) => site.is_active) || []
|
||||
// 加载用户站点选择
|
||||
await loadUserSitePreferences()
|
||||
// 如果没有选择任何站点并且有可用站点,才默认选择全部
|
||||
if (selectedSites.value.length === 0 && allSites.value.length > 0) {
|
||||
selectedSites.value = allSites.value.map((site: Site) => site.id)
|
||||
}
|
||||
} else if (res.data && Array.isArray(res.data)) {
|
||||
allSites.value = res.data.filter((site: any) => site.is_active) || []
|
||||
// 加载用户站点选择
|
||||
await loadUserSitePreferences()
|
||||
// 如果没有选择任何站点并且有可用站点,才默认选择全部
|
||||
if (selectedSites.value.length === 0 && allSites.value.length > 0) {
|
||||
selectedSites.value = allSites.value.map((site: Site) => site.id)
|
||||
}
|
||||
}
|
||||
console.log('站点数据:', allSites.value)
|
||||
console.log('已选站点:', selectedSites.value)
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('获取站点数据失败:', err)
|
||||
})
|
||||
}
|
||||
|
||||
// 打开站点选择对话框
|
||||
const openSiteDialog = () => {
|
||||
chooseSiteDialog.value = true
|
||||
}
|
||||
|
||||
// 匹配的订阅列表
|
||||
const matchedSubscribeItems = computed(() => {
|
||||
if (!searchWord.value) return []
|
||||
const lowerWord = (searchWord.value as string).toLowerCase()
|
||||
return SubscribeItems.value.filter((item: Subscribe) => {
|
||||
return (item.name.toLowerCase().includes(lowerWord) && (superUser || userName === item.username)) || false
|
||||
})
|
||||
})
|
||||
|
||||
// 搜索多站点
|
||||
function searchSites(sites: number[]) {
|
||||
chooseSiteDialog.value = false
|
||||
selectedSites.value = sites
|
||||
searchTorrent()
|
||||
}
|
||||
|
||||
// 选择站点
|
||||
function chooseSitesDone(sites: number[]) {
|
||||
chooseSiteDialog.value = false
|
||||
selectedSites.value = sites
|
||||
}
|
||||
|
||||
// 搜索资源
|
||||
function searchTorrent() {
|
||||
if (!searchWord.value) return
|
||||
// 记录搜索词
|
||||
saveRecentSearches(searchWord.value)
|
||||
// 保存用户站点选择
|
||||
saveUserSitePreferences()
|
||||
// 跳转到搜索页面
|
||||
router.push({
|
||||
path: '/resource',
|
||||
query: {
|
||||
keyword: searchWord.value,
|
||||
area: 'title',
|
||||
sites: selectedSites.value.join(','),
|
||||
},
|
||||
})
|
||||
// 关闭搜索对话框
|
||||
dialog.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 跳转媒体搜索页面
|
||||
function searchMedia(searchType: string) {
|
||||
// 搜索类型 media/person
|
||||
if (!searchWord.value) return
|
||||
saveRecentSearches(searchWord.value)
|
||||
router.push({
|
||||
path: '/browse/media/search',
|
||||
query: {
|
||||
title: searchWord.value,
|
||||
type: searchType,
|
||||
},
|
||||
})
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 跳转到历史记录页面
|
||||
function searchHistory() {
|
||||
if (!searchWord.value) return
|
||||
saveRecentSearches(searchWord.value)
|
||||
router.push({
|
||||
path: '/history',
|
||||
query: {
|
||||
search: searchWord.value,
|
||||
},
|
||||
})
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 跳转插件页面
|
||||
function showPlugin(pluginId: string) {
|
||||
router.push({
|
||||
path: `/plugins/`,
|
||||
query: {
|
||||
tab: 'installed',
|
||||
id: pluginId,
|
||||
},
|
||||
})
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 跳转菜单页面
|
||||
function goPage(to: string) {
|
||||
router.push(to)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 跳转订阅页面
|
||||
function goSubscribe(subscribe: Subscribe) {
|
||||
if (subscribe.type === '电影') {
|
||||
router.push({
|
||||
path: '/subscribe/movie',
|
||||
query: {
|
||||
id: subscribe.id,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
router.push({
|
||||
path: '/subscribe/tv',
|
||||
query: {
|
||||
id: subscribe.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
emit('close')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
searchWordInput.value?.focus()
|
||||
}, 500)
|
||||
fetchInstalledPlugins()
|
||||
fetchSubscribes()
|
||||
loadRecentSearches()
|
||||
getSiteCategories()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VDialog v-model="dialog" max-width="42rem" scrollable>
|
||||
<VCard class="search-dialog">
|
||||
<!-- 搜索输入框 -->
|
||||
<VCardItem class="pa-4 pa-sm-5 search-box-container">
|
||||
<VCombobox
|
||||
ref="searchWordInput"
|
||||
v-model="searchWord"
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
class="search-input"
|
||||
placeholder="输入关键词搜索..."
|
||||
@keydown.enter="searchMedia('media')"
|
||||
hide-details
|
||||
clearable
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-magnify" color="primary" class="search-icon" />
|
||||
</template>
|
||||
</VCombobox>
|
||||
<DialogCloseBtn inner-class="close-btn" @click="emit('close')">
|
||||
<template #default>
|
||||
<VIcon icon="mdi-close-circle" color="error" />
|
||||
</template>
|
||||
</DialogCloseBtn>
|
||||
</VCardItem>
|
||||
|
||||
<VDivider class="search-divider" />
|
||||
|
||||
<!-- 主搜索结果区域 -->
|
||||
<VCardText class="search-results-container pa-0">
|
||||
<!-- 有搜索词时显示结果 -->
|
||||
<VList lines="two" v-if="searchWord" class="search-list py-2">
|
||||
<!-- 搜索结果分组标题 -->
|
||||
<VListSubheader class="primary-text font-weight-medium text-uppercase py-2 px-4 px-sm-6">
|
||||
<span class="category-title">媒体搜索</span>
|
||||
</VListSubheader>
|
||||
|
||||
<!-- 媒体搜索选项 -->
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VListItem
|
||||
density="comfortable"
|
||||
link
|
||||
rounded="xl"
|
||||
v-bind="hover.props"
|
||||
@click="searchMedia('media')"
|
||||
class="search-option mx-2 mx-sm-4 my-1"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="option-icon-wrapper d-flex align-center justify-center">
|
||||
<VIcon
|
||||
icon="mdi-movie-search"
|
||||
:color="hover.isHovering ? 'primary' : 'medium-emphasis'"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="text-subtitle-1 font-weight-medium"> 电影、电视剧 </VListItemTitle>
|
||||
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
|
||||
搜索 <span class="primary-text font-weight-medium">{{ searchWord }}</span> 相关的影视作品
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VHover>
|
||||
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VListItem
|
||||
density="comfortable"
|
||||
link
|
||||
rounded="xl"
|
||||
v-bind="hover.props"
|
||||
@click="searchMedia('collection')"
|
||||
class="search-option mx-2 mx-sm-4 my-1"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="option-icon-wrapper d-flex align-center justify-center">
|
||||
<VIcon
|
||||
icon="mdi-movie-filter"
|
||||
:color="hover.isHovering ? 'primary' : 'medium-emphasis'"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="text-subtitle-1 font-weight-medium"> 系列合集 </VListItemTitle>
|
||||
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
|
||||
搜索 <span class="primary-text font-weight-medium">{{ searchWord }}</span> 相关的系列作品
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VHover>
|
||||
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VListItem
|
||||
density="comfortable"
|
||||
link
|
||||
rounded="xl"
|
||||
v-bind="hover.props"
|
||||
@click="searchMedia('person')"
|
||||
class="search-option mx-2 mx-sm-4 my-1"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="option-icon-wrapper d-flex align-center justify-center">
|
||||
<VIcon
|
||||
icon="mdi-account-search"
|
||||
:color="hover.isHovering ? 'primary' : 'medium-emphasis'"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="text-subtitle-1 font-weight-medium"> 演职人员 </VListItemTitle>
|
||||
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
|
||||
搜索 <span class="primary-text font-weight-medium">{{ searchWord }}</span> 相关的演员、导演等
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VHover>
|
||||
|
||||
<VHover v-if="superUser">
|
||||
<template #default="hover">
|
||||
<VListItem
|
||||
density="comfortable"
|
||||
link
|
||||
rounded="xl"
|
||||
v-bind="hover.props"
|
||||
@click="searchHistory"
|
||||
class="search-option mx-2 mx-sm-4 my-1"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="option-icon-wrapper d-flex align-center justify-center">
|
||||
<VIcon icon="mdi-history" :color="hover.isHovering ? 'primary' : 'medium-emphasis'" size="small" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="text-subtitle-1 font-weight-medium"> 整理记录 </VListItemTitle>
|
||||
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
|
||||
搜索 <span class="primary-text font-weight-medium">{{ searchWord }}</span> 相关的历史记录
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VHover>
|
||||
|
||||
<!-- 其他搜索结果 -->
|
||||
<template v-if="matchedSubscribeItems.length > 0">
|
||||
<VDivider class="mx-4 mx-sm-6 my-2 search-divider" />
|
||||
<VListSubheader class="primary-text font-weight-medium text-uppercase py-2 px-4 px-sm-6">
|
||||
<span class="category-title">订阅内容</span>
|
||||
</VListSubheader>
|
||||
|
||||
<VHover v-for="subscribe in matchedSubscribeItems" :key="subscribe.id">
|
||||
<template #default="hover">
|
||||
<VListItem
|
||||
density="comfortable"
|
||||
link
|
||||
rounded="xl"
|
||||
v-bind="hover.props"
|
||||
@click="goSubscribe(subscribe)"
|
||||
class="search-option mx-2 mx-sm-4 my-1"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="option-icon-wrapper d-flex align-center justify-center">
|
||||
<VIcon
|
||||
:icon="subscribe.type === '电影' ? 'mdi-movie-roll' : 'mdi-television-classic'"
|
||||
:color="hover.isHovering ? 'primary' : 'medium-emphasis'"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="text-subtitle-1 font-weight-medium">
|
||||
{{ subscribe.name
|
||||
}}<span v-if="subscribe.season" class="text-body-2"> 第 {{ subscribe.season }} 季</span>
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
|
||||
{{ subscribe.type }}
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<template v-if="matchedMenuItems.length > 0">
|
||||
<VDivider class="mx-4 mx-sm-6 my-2 search-divider" />
|
||||
<VListSubheader class="primary-text font-weight-medium text-uppercase py-2 px-4 px-sm-6">
|
||||
<span class="category-title">功能菜单</span>
|
||||
</VListSubheader>
|
||||
|
||||
<VHover v-for="menu in matchedMenuItems" :key="menu.title">
|
||||
<template #default="hover">
|
||||
<VListItem
|
||||
density="comfortable"
|
||||
link
|
||||
rounded="xl"
|
||||
v-bind="hover.props"
|
||||
@click="goPage(menu.to as string)"
|
||||
class="search-option mx-2 mx-sm-4 my-1"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="option-icon-wrapper d-flex align-center justify-center">
|
||||
<VIcon
|
||||
:icon="menu.icon as string"
|
||||
:color="hover.isHovering ? 'primary' : 'medium-emphasis'"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="text-subtitle-1 font-weight-medium">
|
||||
{{ menu.title }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle v-if="menu.description" class="text-body-2 text-medium-emphasis mt-1">
|
||||
{{ menu.description }}
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<template v-if="matchedPluginItems.length > 0">
|
||||
<VDivider class="mx-4 mx-sm-6 my-2 search-divider" />
|
||||
<VListSubheader class="primary-text font-weight-medium text-uppercase py-2 px-4 px-sm-6">
|
||||
<span class="category-title">插件</span>
|
||||
</VListSubheader>
|
||||
|
||||
<VHover v-for="plugin in matchedPluginItems" :key="plugin.id">
|
||||
<template #default="hover">
|
||||
<VListItem
|
||||
density="comfortable"
|
||||
link
|
||||
rounded="xl"
|
||||
v-bind="hover.props"
|
||||
@click="showPlugin(plugin.id ?? '')"
|
||||
class="search-option mx-2 mx-sm-4 my-1"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="option-icon-wrapper d-flex align-center justify-center">
|
||||
<VIcon icon="mdi-puzzle" :color="hover.isHovering ? 'primary' : 'medium-emphasis'" size="small" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="text-subtitle-1 font-weight-medium">
|
||||
{{ plugin.plugin_name }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
|
||||
{{ plugin.plugin_desc }}
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<!-- 将站点资源搜索移到最底部 -->
|
||||
<template v-if="searchWord">
|
||||
<VDivider class="mx-4 mx-sm-6 my-2 search-divider" />
|
||||
<VListSubheader class="primary-text font-weight-medium text-uppercase py-2 px-4 px-sm-6">
|
||||
<span class="category-title">站点资源搜索</span>
|
||||
</VListSubheader>
|
||||
|
||||
<VCard class="mx-3 mx-sm-6 mb-4 mt-2 site-search-card">
|
||||
<VCardText class="pa-3 pa-sm-4">
|
||||
<div class="d-flex flex-column">
|
||||
<div class="d-flex align-center mb-3">
|
||||
<div class="search-icon-wrapper mr-3">
|
||||
<VIcon icon="mdi-file-search" color="primary" size="small" />
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="font-weight-medium text-body-1">在站点中搜索种子资源</div>
|
||||
<div class="text-caption text-medium-emphasis mt-1">
|
||||
搜索 <span class="primary-text font-weight-medium">{{ searchWord }}</span> 相关资源
|
||||
</div>
|
||||
</div>
|
||||
<VBtn
|
||||
color="primary"
|
||||
@click="searchTorrent"
|
||||
prepend-icon="mdi-magnify"
|
||||
size="small"
|
||||
variant="flat"
|
||||
rounded="pill"
|
||||
class="search-btn"
|
||||
>
|
||||
搜索
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center flex-wrap site-chips-container mt-1 py-2 px-2 px-sm-3">
|
||||
<div class="d-flex align-center flex-wrap flex-grow-1">
|
||||
<VChip
|
||||
v-if="selectedSites.length > 0"
|
||||
color="primary"
|
||||
size="small"
|
||||
variant="flat"
|
||||
class="mr-2 mb-1 font-weight-medium"
|
||||
>
|
||||
{{ selectedSites.length }}/{{ allSites.length }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-for="(site, index) in allSites.filter(s => selectedSites.includes(s.id)).slice(0, 5)"
|
||||
:key="site.id"
|
||||
size="x-small"
|
||||
variant="outlined"
|
||||
class="mr-1 mb-1 site-chip"
|
||||
>
|
||||
{{ site.name }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="selectedSites.length > 5"
|
||||
size="x-small"
|
||||
variant="outlined"
|
||||
class="mr-1 mb-1 site-chip text-medium-emphasis"
|
||||
>
|
||||
+{{ selectedSites.length - 5 }}
|
||||
</VChip>
|
||||
</div>
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
@click="openSiteDialog"
|
||||
class="ml-auto site-select-btn"
|
||||
>
|
||||
选择站点
|
||||
<VIcon size="small" class="ml-1">mdi-cog-outline</VIcon>
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
</VList>
|
||||
|
||||
<!-- 无搜索词时显示最近搜索和提示 -->
|
||||
<div v-else class="recent-searches py-6 px-4 px-sm-6">
|
||||
<div v-if="recentSearches.length > 0" class="mb-6">
|
||||
<div class="text-h6 font-weight-medium mb-3">最近搜索</div>
|
||||
<div class="d-flex flex-wrap">
|
||||
<VChip
|
||||
v-for="(word, index) in recentSearches"
|
||||
:key="index"
|
||||
class="me-2 mb-2"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
size="small"
|
||||
@click="searchWord = word"
|
||||
>
|
||||
<VIcon start size="x-small">mdi-history</VIcon>
|
||||
{{ word }}
|
||||
</VChip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-6 py-6 empty-search-state">
|
||||
<div class="search-icon-wrapper mx-auto mb-4">
|
||||
<VIcon icon="mdi-magnify" size="large" color="primary" />
|
||||
</div>
|
||||
<div class="text-h6 font-weight-medium mb-2">输入关键词开始搜索</div>
|
||||
<div class="text-body-2 text-medium-emphasis">可搜索电影、电视剧、演员、资源等</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<!-- 站点选择对话框 -->
|
||||
<SearchSiteDialog
|
||||
v-if="chooseSiteDialog"
|
||||
v-model="chooseSiteDialog"
|
||||
:sites="allSites"
|
||||
:selected="selectedSites"
|
||||
:savebtn="true"
|
||||
@search="searchSites"
|
||||
@close="chooseSiteDialog = false"
|
||||
@reload="getSiteCategories"
|
||||
@save="chooseSitesDone"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-dialog {
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.site-dialog {
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.search-divider {
|
||||
opacity: 0.08;
|
||||
}
|
||||
|
||||
.search-box-container {
|
||||
position: relative;
|
||||
background-color: rgb(var(--v-theme-background));
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
right: 1.2rem;
|
||||
top: 1.4rem;
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.04);
|
||||
border-radius: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background-color: rgba(var(--v-theme-error), 0.1);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
.search-input :deep(.v-field__input) {
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.search-list {
|
||||
background-color: rgb(var(--v-theme-background));
|
||||
}
|
||||
|
||||
.category-title {
|
||||
font-size: 12px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.option-icon-wrapper {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.12);
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.search-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
.search-icon-wrapper.warning {
|
||||
background-color: rgba(var(--v-theme-warning), 0.08);
|
||||
}
|
||||
|
||||
.primary-text {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.search-option {
|
||||
transition: transform 0.2s ease, background-color 0.2s ease;
|
||||
margin-bottom: 2px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.search-option:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.04);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.recent-searches {
|
||||
min-height: 200px;
|
||||
background-color: rgb(var(--v-theme-background));
|
||||
}
|
||||
|
||||
.site-search-card {
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: 14px;
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
}
|
||||
|
||||
.site-chip {
|
||||
transition: all 0.2s ease;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.site-chip:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.1);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
min-width: 70px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.empty-search-state,
|
||||
.empty-site-state {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.clear-icon {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.clear-icon:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.site-select-btn {
|
||||
min-height: 32px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.site-chips-container {
|
||||
border-radius: 10px;
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.06);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.search-box-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
right: 0.8rem;
|
||||
top: 1rem;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.site-chips-container {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.site-select-btn {
|
||||
min-height: 28px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
224
src/components/dialog/SearchSiteDialog.vue
Normal file
224
src/components/dialog/SearchSiteDialog.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<script setup lang="ts">
|
||||
import type { Site, Plugin, Subscribe } from '@/api/types'
|
||||
import { popScopeId, PropType } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
sites: {
|
||||
type: Array as PropType<Site[]>,
|
||||
required: true,
|
||||
},
|
||||
selected: Array as PropType<Number[]>,
|
||||
savebtn: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close', 'search', 'reload', 'save'])
|
||||
|
||||
// 过滤词
|
||||
const siteFilter = ref('')
|
||||
|
||||
// 已选择站点
|
||||
const selectedSites = ref<any[]>(props.selected || [])
|
||||
|
||||
watch(() => props.selected, value => {
|
||||
if (selectedSites.value.length == 0 && value) {
|
||||
selectedSites.value = value
|
||||
}
|
||||
})
|
||||
|
||||
// 全选/全不选按钮文字
|
||||
const checkAllText = computed(() => {
|
||||
return selectedSites.value.length < props.sites?.length ? '选择全部' : '取消全选'
|
||||
})
|
||||
|
||||
// 全选/全不选
|
||||
const checkAllSitesorNot = () => {
|
||||
if (selectedSites.value.length < props.sites?.length) {
|
||||
selectedSites.value = props.sites?.map((item: Site) => item.id)
|
||||
} else {
|
||||
selectedSites.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 根据筛选条件过滤站点
|
||||
const filteredSites = computed(() => {
|
||||
if (!siteFilter.value) return props.sites
|
||||
const filter = siteFilter.value.toLowerCase()
|
||||
return props.sites?.filter((site: Site) => site.name.toLowerCase().includes(filter))
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<!-- 手动整理进度框 -->
|
||||
<VDialog max-width="40rem" fullscreen-mobile>
|
||||
<VCard class="site-dialog">
|
||||
<VCardTitle class="d-flex align-center pa-4">
|
||||
<span class="text-h6 font-weight-medium">选择搜索站点</span>
|
||||
<VSpacer />
|
||||
<VTextField
|
||||
v-model="siteFilter"
|
||||
placeholder="过滤站点..."
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
class="ml-4"
|
||||
style="max-width: 200px"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
clearable
|
||||
/>
|
||||
</VCardTitle>
|
||||
<VDivider class="search-divider" />
|
||||
|
||||
<VCardText style="max-height: 420px" class="overflow-y-auto px-4 py-4">
|
||||
<!-- 站点列表 -->
|
||||
<div v-if="filteredSites.length > 0">
|
||||
<!-- 选择操作 -->
|
||||
<div class="d-flex align-center mb-4">
|
||||
<VBtn
|
||||
size="small"
|
||||
:color="selectedSites.length < sites.length ? 'primary' : 'error'"
|
||||
@click="checkAllSitesorNot"
|
||||
class="me-2"
|
||||
rounded="pill"
|
||||
variant="flat"
|
||||
>
|
||||
<VIcon start size="small">
|
||||
{{ selectedSites.length < sites.length ? 'mdi-check-all' : 'mdi-close-circle-outline' }}
|
||||
</VIcon>
|
||||
{{ checkAllText }}
|
||||
</VBtn>
|
||||
<div
|
||||
class="text-body-2 font-weight-medium"
|
||||
:class="selectedSites.length > 0 ? 'text-primary' : 'text-medium-emphasis'"
|
||||
>
|
||||
已选择 {{ selectedSites.length }}/{{ sites.length }} 个站点
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 站点选择器 -->
|
||||
<VRow dense>
|
||||
<VCol v-for="site in filteredSites" :key="site.id" cols="6" sm="6" md="4">
|
||||
<VHover v-slot="{ isHovering, props }">
|
||||
<div
|
||||
v-bind="props"
|
||||
:class="[
|
||||
'site-checkbox-wrapper pa-2 pa-sm-3 rounded-lg d-flex align-center',
|
||||
{
|
||||
'site-selected': selectedSites.includes(site.id),
|
||||
'site-hover': isHovering && !selectedSites.includes(site.id),
|
||||
},
|
||||
]"
|
||||
@click="
|
||||
() => {
|
||||
const index = selectedSites.indexOf(site.id)
|
||||
if (index === -1) {
|
||||
selectedSites.push(site.id)
|
||||
} else {
|
||||
selectedSites.splice(index, 1)
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<VIcon
|
||||
:icon="selectedSites.includes(site.id) ? 'mdi-check-circle' : 'mdi-checkbox-blank-circle-outline'"
|
||||
:color="selectedSites.includes(site.id) ? 'primary' : 'medium-emphasis'"
|
||||
class="me-2"
|
||||
size="small"
|
||||
/>
|
||||
<span :class="['text-body-2 site-name', { 'font-weight-medium': selectedSites.includes(site.id) }]">
|
||||
{{ site.name }}
|
||||
</span>
|
||||
</div>
|
||||
</VHover>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
<div v-else class="text-center py-8 empty-site-state">
|
||||
<div class="search-icon-wrapper mb-4 mx-auto warning">
|
||||
<VIcon icon="mdi-alert-circle-outline" size="large" color="warning" />
|
||||
</div>
|
||||
<div class="text-h6 font-weight-medium mb-2">没有找到匹配的站点</div>
|
||||
<div class="text-subtitle-1 text-medium-emphasis mb-4">
|
||||
{{ siteFilter ? '请尝试修改过滤条件' : '站点数据加载失败,请刷新页面重试' }}
|
||||
</div>
|
||||
<VBtn
|
||||
v-if="siteFilter"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
class="mt-3"
|
||||
prepend-icon="mdi-refresh"
|
||||
@click="siteFilter = ''"
|
||||
>
|
||||
重置
|
||||
</VBtn>
|
||||
<VBtn v-else color="primary" variant="flat" class="mt-3" prepend-icon="mdi-refresh" @click="emit('reload')">
|
||||
重新加载站点
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<VDivider class="search-divider" />
|
||||
|
||||
<VCardActions class="pa-4">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="grey-darken-1"
|
||||
variant="text"
|
||||
@click="emit('close')"
|
||||
class="mr-2 d-flex align-center justify-center"
|
||||
>
|
||||
取消
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="savebtn"
|
||||
color="success"
|
||||
variant="flat"
|
||||
@click="emit('save', selectedSites)"
|
||||
class="mr-2 d-flex align-center justify-center"
|
||||
:disabled="selectedSites.length === 0"
|
||||
>
|
||||
确定
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:disabled="selectedSites.length === 0"
|
||||
@click="emit('search', selectedSites)"
|
||||
prepend-icon="mdi-magnify"
|
||||
class="d-flex align-center justify-center px-5"
|
||||
>
|
||||
搜索
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
.site-checkbox-wrapper {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, background-color 0.2s ease;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
}
|
||||
|
||||
.site-checkbox-wrapper:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.site-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.site-selected {
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
border-color: rgba(var(--v-theme-primary), 0.2);
|
||||
}
|
||||
|
||||
.site-hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.04);
|
||||
}
|
||||
</style>
|
||||
@@ -5,14 +5,10 @@ import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import { numberValidator, requiredValidator } from '@/@validators'
|
||||
import api from '@/api'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
siteid: Number,
|
||||
@@ -108,25 +104,6 @@ async function addSite() {
|
||||
doneNProgress()
|
||||
}
|
||||
|
||||
// 调用API删除站点信息
|
||||
async function deleteSiteInfo() {
|
||||
const isConfirmed = await createConfirm({
|
||||
title: '确认',
|
||||
content: `是否确认删除站点?`,
|
||||
})
|
||||
|
||||
if (!isConfirmed) return
|
||||
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.delete(`site/${siteForm.value?.id}`)
|
||||
if (result.success) emit('remove')
|
||||
else $toast.error(`${siteForm.value?.name} 删除失败:${result.message}`)
|
||||
} catch (error) {
|
||||
$toast.error(`${siteForm.value?.name} 删除失败!`)
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API更新站点信息
|
||||
async function updateSiteInfo() {
|
||||
startNProgress()
|
||||
@@ -166,7 +143,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog scrollable :close-on-back="false" persistent eager max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VDialog scrollable :close-on-back="false" eager max-width="45rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard
|
||||
:title="`${props.oper === 'add' ? '新增' : '编辑'}站点${props.oper !== 'add' ? ` - ${siteForm.name}` : ''}`"
|
||||
class="rounded-t"
|
||||
@@ -338,9 +315,6 @@ onMounted(async () => {
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn v-if="props.oper !== 'add'" color="error" @click="deleteSiteInfo" variant="outlined" class="me-3">
|
||||
删除
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
v-if="props.oper === 'add'"
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { Site } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import api from '@/api'
|
||||
import type { TorrentInfo } from '@/api/types'
|
||||
import type { TorrentInfo, SiteCategory } from '@/api/types'
|
||||
import { formatFileSize } from '@core/utils/formatters'
|
||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
site: Object as PropType<Site>,
|
||||
})
|
||||
|
||||
// 关键字
|
||||
const keyword = ref<string>()
|
||||
|
||||
// 选择分类
|
||||
const selectCategory = ref<number[]>([])
|
||||
|
||||
// 全部分类
|
||||
const siteCategoryList = ref<SiteCategory[]>()
|
||||
|
||||
// 分类选项
|
||||
const categoryOptions = computed(() => {
|
||||
return siteCategoryList.value?.map(item => {
|
||||
return { title: item.desc, value: item.id }
|
||||
})
|
||||
})
|
||||
|
||||
// 注册事件
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
@@ -55,17 +67,6 @@ async function downloadTorrentFile(enclosure: string) {
|
||||
window.open(enclosure, '_blank')
|
||||
}
|
||||
|
||||
// 调用API,查询站点资源
|
||||
async function getResourceList() {
|
||||
resourceLoading.value = true
|
||||
try {
|
||||
resourceDataList.value = await api.get(`site/resource/${props.site?.id}`)
|
||||
resourceLoading.value = false
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 促销Chip类
|
||||
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
||||
if (downloadVolume === 0) return 'text-white bg-lime-500'
|
||||
@@ -93,17 +94,75 @@ function addDownloadError(error: string) {
|
||||
addDownloadDialog.value = false
|
||||
}
|
||||
|
||||
// 调用API,查询站点资源
|
||||
async function getResourceList() {
|
||||
resourceLoading.value = true
|
||||
try {
|
||||
resourceDataList.value = await api.get(`site/resource/${props.site?.id}`, {
|
||||
params: {
|
||||
keyword: keyword.value,
|
||||
cat: selectCategory.value?.join(','),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
resourceLoading.value = false
|
||||
}
|
||||
|
||||
// 加载站点分类
|
||||
async function getSiteCategoryList() {
|
||||
try {
|
||||
siteCategoryList.value = await api.get(`site/category/${props.site?.id}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
onMounted(() => {
|
||||
getSiteCategoryList()
|
||||
getResourceList()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VDialog max-width="80rem" scrollable z-index="1010" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard :title="`浏览 - ${props.site?.name}`">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
<VCardText class="pt-2">
|
||||
<VDialog scrollable fullscreen :scrim="false" transition="dialog-bottom-transition">
|
||||
<VCard>
|
||||
<!-- Toolbar -->
|
||||
<div>
|
||||
<VToolbar color="primary">
|
||||
<VToolbarTitle>{{ `浏览 - ${props.site?.name}` }}</VToolbarTitle>
|
||||
<VSpacer />
|
||||
<VToolbarItems>
|
||||
<VBtn icon variant="plain" @click="emit('close')" class="me-3">
|
||||
<VIcon size="large" color="white" icon="ri-close-line" />
|
||||
</VBtn>
|
||||
</VToolbarItems>
|
||||
</VToolbar>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<VRow>
|
||||
<VCol cols="6" md="5">
|
||||
<VTextField v-model="keyword" size="small" density="compact" label="搜索关键字" clearable />
|
||||
</VCol>
|
||||
<VCol cols="6" md="5">
|
||||
<VSelect
|
||||
v-model="selectCategory"
|
||||
:items="categoryOptions"
|
||||
size="small"
|
||||
density="compact"
|
||||
chips
|
||||
label="资源分类"
|
||||
multiple
|
||||
clearable
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="2" class="text-center">
|
||||
<VBtn block prepend-icon="mdi-magnify" @click="getResourceList">搜索</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
<VCardText class="px-0 py-0 my-0">
|
||||
<VDataTable
|
||||
v-model:items-per-page="resourceItemsPerPage"
|
||||
:headers="resourceHeaders"
|
||||
@@ -119,6 +178,7 @@ onMounted(() => {
|
||||
items-per-page-text="每页条数"
|
||||
page-text="{0}-{1} 共 {2} 条"
|
||||
loading-text="加载中..."
|
||||
class="h-full"
|
||||
>
|
||||
<template #item.title="{ item }">
|
||||
<a href="javascript:void(0)" @click.stop="addDownload(item)">
|
||||
|
||||
@@ -54,6 +54,8 @@ const historyChartOptions = computed(() => {
|
||||
parentHeightOffset: 0,
|
||||
toolbar: { show: false },
|
||||
animations: { enabled: true },
|
||||
background: currentTheme.value.surface, // 新增背景色同步
|
||||
foreColor: currentTheme.value.onSurface, // 新增文字颜色同步
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
},
|
||||
@@ -61,6 +63,9 @@ const historyChartOptions = computed(() => {
|
||||
autoScaleYaxis: true,
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
mode: vuetifyTheme.global.current.value.dark ? 'dark' : 'light', // 同步主题模式
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
tooltip: {
|
||||
@@ -68,6 +73,10 @@ const historyChartOptions = computed(() => {
|
||||
format: 'dd MMM yyyy',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
background: currentTheme.value.background, // 提示框背景色同步
|
||||
color: currentTheme.value.onBackground, // 文字颜色同步
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
xaxis: {
|
||||
@@ -140,10 +149,15 @@ const seedingChartOptions = computed(() => {
|
||||
parentHeightOffset: 0,
|
||||
toolbar: { show: false },
|
||||
animations: { enabled: true },
|
||||
background: currentTheme.value.surface, // 新增背景色同步
|
||||
foreColor: currentTheme.value.onSurface, // 新增文字颜色同步
|
||||
zoom: {
|
||||
autoScaleYaxis: true,
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
mode: vuetifyTheme.global.current.value.dark ? 'dark' : 'light', // 同步主题模式
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
x: {
|
||||
@@ -151,6 +165,10 @@ const seedingChartOptions = computed(() => {
|
||||
return '数量:' + val.toLocaleString()
|
||||
},
|
||||
},
|
||||
style: {
|
||||
background: currentTheme.value.background, // 提示框背景色同步
|
||||
color: currentTheme.value.onBackground, // 文字颜色同步
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
xaxis: {
|
||||
@@ -277,7 +295,7 @@ onBeforeMount(async () => {
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1">
|
||||
<div class="d-flex flex-column gap-y-1 overflow-hidden">
|
||||
<span class="text-base">用户等级</span>
|
||||
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
|
||||
{{ siteData?.user_level || '无' }}
|
||||
@@ -295,7 +313,7 @@ onBeforeMount(async () => {
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1">
|
||||
<div class="d-flex flex-column gap-y-1 overflow-hidden">
|
||||
<span class="text-base">积分</span>
|
||||
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
|
||||
{{ siteData?.bonus?.toLocaleString() }}
|
||||
@@ -337,7 +355,7 @@ onBeforeMount(async () => {
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1">
|
||||
<div class="d-flex flex-column gap-y-1 overflow-hidden">
|
||||
<span class="text-base">总上传量</span>
|
||||
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
|
||||
{{ formatFileSize(siteData?.upload || 0) }}
|
||||
@@ -358,7 +376,7 @@ onBeforeMount(async () => {
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1">
|
||||
<div class="d-flex flex-column gap-y-1 overflow-hidden">
|
||||
<span class="text-base">总下载量</span>
|
||||
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
|
||||
{{ formatFileSize(siteData?.download || 0) }}
|
||||
@@ -379,7 +397,7 @@ onBeforeMount(async () => {
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1">
|
||||
<div class="d-flex flex-column gap-y-1 overflow-hidden">
|
||||
<span class="text-base">总做种数</span>
|
||||
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
|
||||
{{ siteData?.seeding?.toLocaleString() }}
|
||||
@@ -400,7 +418,7 @@ onBeforeMount(async () => {
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1">
|
||||
<div class="d-flex flex-column gap-y-1 overflow-hidden">
|
||||
<span class="text-base">总做种体积</span>
|
||||
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
|
||||
{{ formatFileSize(siteData?.seeding_size || 0) }}
|
||||
@@ -421,7 +439,7 @@ onBeforeMount(async () => {
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1">
|
||||
<div class="d-flex flex-column gap-y-1 overflow-hidden">
|
||||
<span class="text-base">加入时间</span>
|
||||
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
|
||||
{{ siteData?.join_at?.split(' ')[0] }}
|
||||
|
||||
@@ -5,7 +5,6 @@ import api from '@/api'
|
||||
import type { DownloaderConf, FilterRuleGroup, Site, Subscribe, TransferDirectoryConf } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
import { VTextarea, VTextField } from 'vuetify/lib/components/index.mjs'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -53,6 +52,7 @@ const subscribeForm = ref<Subscribe>({
|
||||
downloader: '',
|
||||
date: '',
|
||||
show_edit_dialog: false,
|
||||
episode_group: '',
|
||||
})
|
||||
|
||||
// 提示框
|
||||
@@ -61,6 +61,47 @@ const $toast = useToast()
|
||||
// 下载器选项
|
||||
const downloaderOptions = ref<{ title: string; value: string }[]>([])
|
||||
|
||||
// 所有剧集组
|
||||
const episodeGroups = ref<{ [key: string]: any }[]>([])
|
||||
|
||||
// 剧集组选项
|
||||
const episodeGroupOptions = computed(() => {
|
||||
return (episodeGroups.value as { id: number; name: string; group_count: number; episode_count: number }[]).map(
|
||||
item => {
|
||||
return {
|
||||
title: item.name,
|
||||
subtitle: `${item.group_count} 季 • ${item.episode_count} 集`,
|
||||
value: item.id,
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// 生成1到100季的下拉框选项
|
||||
const seasonItems = ref(
|
||||
Array.from({ length: 101 }, (_, i) => i).map(item => ({
|
||||
title: `第 ${item} 季`,
|
||||
value: item,
|
||||
})),
|
||||
)
|
||||
|
||||
// 剧集组选项属性
|
||||
function episodeGroupItemProps(item: { title: string; subtitle: string }) {
|
||||
return {
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
}
|
||||
}
|
||||
|
||||
// 查询所有剧集组
|
||||
async function getEpisodeGroups() {
|
||||
try {
|
||||
episodeGroups.value = await api.get(`media/groups/${subscribeForm.value.tmdbid}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDownloaderSetting() {
|
||||
try {
|
||||
const downloaders: DownloaderConf[] = await api.get('download/clients')
|
||||
@@ -178,6 +219,8 @@ async function getSubscribeInfo() {
|
||||
subscribeForm.value = result
|
||||
subscribeForm.value.best_version = subscribeForm.value.best_version === 1
|
||||
subscribeForm.value.search_imdbid = subscribeForm.value.search_imdbid === 1
|
||||
// 加载剧集组
|
||||
if (subscribeForm.value.type == '电视剧') getEpisodeGroups()
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
@@ -317,7 +360,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VDialog scrollable max-width="45rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard
|
||||
:title="`${
|
||||
props.default
|
||||
@@ -480,7 +523,7 @@ onMounted(() => {
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VCol cols="12">
|
||||
<VSelect
|
||||
v-model="subscribeForm.filter_groups"
|
||||
:items="filterRuleGroupOptions"
|
||||
@@ -492,7 +535,26 @@ onMounted(() => {
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6" v-if="!props.default">
|
||||
<VCol v-if="!props.default && subscribeForm.type === '电视剧'" cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="subscribeForm.episode_group"
|
||||
:items="episodeGroupOptions"
|
||||
:item-props="episodeGroupItemProps"
|
||||
label="指定剧集组"
|
||||
hint="按特定剧集组识别和刮削"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="!props.default && subscribeForm.type === '电视剧'" cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="subscribeForm.season"
|
||||
:items="seasonItems"
|
||||
label="指定季"
|
||||
hint="指定任意季订阅"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" v-if="!props.default">
|
||||
<VTextField
|
||||
v-model="subscribeForm.media_category"
|
||||
label="自定义类别"
|
||||
|
||||
@@ -198,7 +198,7 @@ onBeforeMount(() => {
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
<style lang="scss" scoped>
|
||||
.vue-media-back {
|
||||
background-image: linear-gradient(
|
||||
180deg,
|
||||
|
||||
262
src/components/dialog/SubscribeSeasonDialog.vue
Normal file
262
src/components/dialog/SubscribeSeasonDialog.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import { MediaInfo, MediaSeason, NotExistMediaInfo } from '@/api/types'
|
||||
import { PropType } from 'vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['subscribe', 'close'])
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
media: Object as PropType<MediaInfo>,
|
||||
})
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettings: any = inject('globalSettings')
|
||||
|
||||
// 季详情
|
||||
const seasonInfos = ref<MediaSeason[]>([])
|
||||
|
||||
// 选中的订阅季
|
||||
const seasonsSelected = ref<MediaSeason[]>([])
|
||||
|
||||
// 各季缺失状态:0-已入库 1-部分缺失 2-全部缺失,没有数据也是已入库
|
||||
const seasonsNotExisted = ref<{ [key: number]: number }>({})
|
||||
|
||||
// 是否刷新过
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
// 所有剧集组
|
||||
const episodeGroups = ref<{ [key: string]: any }[]>([])
|
||||
|
||||
// 当前选择剧集组
|
||||
const episodeGroup = ref('')
|
||||
|
||||
// 剧集组选项属性
|
||||
function episodeGroupItemProps(item: { title: string; subtitle: string }) {
|
||||
return {
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
}
|
||||
}
|
||||
|
||||
// 剧集组选项
|
||||
const episodeGroupOptions = computed(() => {
|
||||
let options = (episodeGroups.value as { id: string; name: string; group_count: number; episode_count: number }[]).map(
|
||||
item => {
|
||||
return {
|
||||
title: item.name,
|
||||
subtitle: `${item.group_count} 季 • ${item.episode_count} 集`,
|
||||
value: item.id,
|
||||
}
|
||||
},
|
||||
)
|
||||
// 添加不使用选项
|
||||
options.unshift({
|
||||
title: '默认',
|
||||
subtitle: `${seasonInfos.value.length} 季`,
|
||||
value: '',
|
||||
})
|
||||
return options
|
||||
})
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
if (props.media?.tmdb_id) return `tmdb:${props.media?.tmdb_id}`
|
||||
else if (props.media?.douban_id) return `douban:${props.media?.douban_id}`
|
||||
else if (props.media?.bangumi_id) return `bangumi:${props.media?.bangumi_id}`
|
||||
else return `${props.media?.mediaid_prefix}:${props.media?.media_id}`
|
||||
}
|
||||
|
||||
// 查询所有剧集组
|
||||
async function getEpisodeGroups() {
|
||||
try {
|
||||
episodeGroups.value = await api.get(`media/groups/${props.media?.tmdb_id}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询TMDB的所有季信息
|
||||
async function getMediaSeasons() {
|
||||
try {
|
||||
seasonInfos.value = await api.get('media/seasons', {
|
||||
params: {
|
||||
mediaid: getMediaId(),
|
||||
title: props.media?.title,
|
||||
year: props.media?.year,
|
||||
season: props.media?.season,
|
||||
},
|
||||
})
|
||||
isRefreshed.value = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询剧集组的剧集
|
||||
async function getGroupSeasons() {
|
||||
if (!episodeGroup.value) return
|
||||
isRefreshed.value = false
|
||||
try {
|
||||
seasonInfos.value = await api.get(`media/group/seasons/${episodeGroup.value}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
isRefreshed.value = true
|
||||
}
|
||||
|
||||
// 检查所有季的缺失状态(数据库)
|
||||
async function checkSeasonsNotExists() {
|
||||
// 开始处理
|
||||
try {
|
||||
let tmpMedia = props.media ?? { episode_group: '' }
|
||||
if (episodeGroup.value) tmpMedia.episode_group = episodeGroup.value
|
||||
else tmpMedia.episode_group = ''
|
||||
const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', tmpMedia)
|
||||
if (result) {
|
||||
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
|
||||
seasonsNotExisted.value[item.season] = state
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算存在状态的颜色
|
||||
function getExistColor(season: number) {
|
||||
const state = seasonsNotExisted.value[season]
|
||||
if (!state) 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 === 1) return '部分缺失'
|
||||
else if (state === 2) return '缺失'
|
||||
else return '已入库'
|
||||
}
|
||||
|
||||
// 拼装季图片地址
|
||||
function getSeasonPoster(posterPath: string) {
|
||||
if (!posterPath) return props.media?.poster_path
|
||||
return `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w500${posterPath}`
|
||||
}
|
||||
|
||||
// 将yyyy-mm-dd转换为yyyy年mm月dd日
|
||||
function formatAirDate(airDate: string) {
|
||||
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.replaceAll(/-/g, '/'))
|
||||
return date.getFullYear()
|
||||
}
|
||||
|
||||
function subscribeSeasons() {
|
||||
emit('subscribe', seasonsSelected.value, seasonsNotExisted.value, episodeGroup.value)
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (episodeGroup.value) getGroupSeasons()
|
||||
else getMediaSeasons()
|
||||
checkSeasonsNotExists()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
getMediaSeasons()
|
||||
getEpisodeGroups()
|
||||
checkSeasonsNotExists()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VBottomSheet inset scrollable>
|
||||
<VCard class="rounded-t">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VCardItem>
|
||||
<VCardTitle class="pe-10"> 订阅 - {{ props.media?.title }} </VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VSelect
|
||||
v-model="episodeGroup"
|
||||
:items="episodeGroupOptions"
|
||||
:item-props="episodeGroupItemProps"
|
||||
label="选择剧集组"
|
||||
persistent-hint
|
||||
/>
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
|
||||
<div v-else-if="seasonInfos.length > 0">
|
||||
<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"
|
||||
width="60"
|
||||
:src="getSeasonPoster(item.poster_path || '')"
|
||||
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> 第 {{ item.season_number }} 季 </VListItemTitle>
|
||||
<VListItemSubtitle class="mt-1 me-2">
|
||||
<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 }} 集
|
||||
</VListItemSubtitle>
|
||||
<VListItemSubtitle>
|
||||
《{{ 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)"
|
||||
>
|
||||
{{ getExistText(item.season_number || 0) }}
|
||||
</VChip>
|
||||
</VListItemSubtitle>
|
||||
<template #append="{ isSelected }">
|
||||
<VListItemAction start>
|
||||
<VSwitch :model-value="isSelected" />
|
||||
</VListItemAction>
|
||||
</template>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</div>
|
||||
<NoDataFound v-else errorTitle="出错啦!" :errorDescription="`${props.media?.title} 未查询到季集信息`" />
|
||||
</VCardText>
|
||||
<div class="my-2 text-center">
|
||||
<VBtn :disabled="seasonsSelected.length === 0" width="30%" @click="subscribeSeasons">
|
||||
{{ seasonsSelected.length === 0 ? '请选择订阅季' : '提交订阅' }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCard>
|
||||
</VBottomSheet>
|
||||
</template>
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import { VCardItem, VTextField } from 'vuetify/lib/components/index.mjs'
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
@@ -18,7 +17,7 @@ const emit = defineEmits(['done', 'close'])
|
||||
const qrCodeContent = ref('')
|
||||
|
||||
// 下方的提示信息
|
||||
const text = ref('请使用微信或115客户端扫码,或在下方输入Cookie')
|
||||
const text = ref('请使用微信或115客户端扫码')
|
||||
|
||||
// 提醒类型
|
||||
const alertType = ref<'success' | 'info' | 'error' | 'warning' | undefined>('info')
|
||||
@@ -29,9 +28,6 @@ let timeoutTimer: NodeJS.Timeout | undefined = undefined
|
||||
// 完成
|
||||
async function handleDone() {
|
||||
clearTimeout(timeoutTimer)
|
||||
if (props.conf?.cookie) {
|
||||
await savaU115Config()
|
||||
}
|
||||
emit('done')
|
||||
}
|
||||
|
||||
@@ -41,6 +37,7 @@ async function getQrcode() {
|
||||
const result: { [key: string]: any } = await api.get('/storage/qrcode/u115')
|
||||
if (result.success && result.data) {
|
||||
qrCodeContent.value = result.data.codeContent
|
||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||
} else {
|
||||
text.value = result.message
|
||||
}
|
||||
@@ -84,18 +81,8 @@ async function checkQrcode() {
|
||||
}
|
||||
}
|
||||
|
||||
// 保存cookie设置
|
||||
async function savaU115Config() {
|
||||
try {
|
||||
await api.post(`storage/save/u115`, props.conf)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await getQrcode()
|
||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -115,13 +102,6 @@ onUnmounted(() => {
|
||||
<template #prepend />
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol class="mt-2">
|
||||
<VTextField label="自定义Cookie" v-model="props.conf.cookie" outlined dense />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import api from '@/api'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import avatar1 from '@images/avatars/avatar-1.png'
|
||||
import store from '@/store'
|
||||
import { useUserStore } from '@/stores'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -23,8 +23,11 @@ const props = defineProps({
|
||||
oper: String,
|
||||
})
|
||||
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 当前登录用户名称
|
||||
const currentLoginUser = store.state.auth.userName
|
||||
const currentLoginUser = userStore.userName
|
||||
|
||||
// 用户名
|
||||
const userName = ref('')
|
||||
@@ -53,8 +56,13 @@ const statusItems = [
|
||||
{ title: '已停用', value: 0 },
|
||||
]
|
||||
|
||||
// 扩展User类型以包含note字段
|
||||
interface ExtendedUser extends User {
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
// 用户编辑表单数据
|
||||
const userForm = ref<User>({
|
||||
const userForm = ref<ExtendedUser>({
|
||||
id: 0,
|
||||
name: props.username ?? '',
|
||||
password: '',
|
||||
@@ -71,6 +79,7 @@ const userForm = ref<User>({
|
||||
vocechat_userid: null,
|
||||
synologychat_userid: null,
|
||||
},
|
||||
nickname: '', // 昵称字段
|
||||
})
|
||||
|
||||
// 更新头像
|
||||
@@ -187,6 +196,15 @@ async function updateUser() {
|
||||
}
|
||||
userForm.value.password = newPassword.value
|
||||
}
|
||||
|
||||
// 将nickname保存到settings中,后端可以直接处理JSON对象
|
||||
if (userForm.value.nickname) {
|
||||
if (!userForm.value.settings) {
|
||||
userForm.value.settings = {}
|
||||
}
|
||||
userForm.value.settings.nickname = userForm.value.nickname
|
||||
}
|
||||
|
||||
const oldUserName = userForm.value.name
|
||||
userForm.value.name = currentUserName.value
|
||||
const oldAvatar = userForm.value.avatar
|
||||
@@ -194,18 +212,24 @@ async function updateUser() {
|
||||
isUpdating.value = true
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.put('user/', userForm.value)
|
||||
// 确保昵称保存,使用一个临时变量存储完整数据
|
||||
const userData = { ...userForm.value }
|
||||
|
||||
const result: { [key: string]: any } = await api.put('user/', userData)
|
||||
|
||||
if (result.success) {
|
||||
if (oldUserName !== currentUserName.value) {
|
||||
$toast.success(`【${oldUserName}】更名【${currentUserName.value}】, 更新成功!`)
|
||||
// 如果是当前登录用户,更新当前用户名称显示
|
||||
if (isCurrentUser.value) store.commit('auth/setUserName', currentUserName.value)
|
||||
if (isCurrentUser.value) {
|
||||
userStore.setUserName(currentUserName.value)
|
||||
}
|
||||
} else {
|
||||
$toast.success(`【${userForm.value?.name}】更新成功!`)
|
||||
}
|
||||
// 更新本地头像显示
|
||||
if (oldAvatar !== currentAvatar.value && isCurrentUser.value) {
|
||||
store.commit('auth/setAvatar', currentAvatar.value)
|
||||
userStore.setAvatar(currentAvatar.value)
|
||||
}
|
||||
emit('save')
|
||||
} else {
|
||||
@@ -224,7 +248,7 @@ async function updateUser() {
|
||||
userForm.value.password = ''
|
||||
} catch (error) {
|
||||
$toast.error(`【${userForm.value?.name}】更新失败!`)
|
||||
console.error(error)
|
||||
console.error('更新失败:', error)
|
||||
}
|
||||
doneNProgress()
|
||||
isUpdating.value = false
|
||||
@@ -262,7 +286,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog scrollable :close-on-back="false" persistent eager max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VDialog scrollable :close-on-back="false" eager max-width="40rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard
|
||||
:title="`${props.oper === 'add' ? '新增' : '编辑'}用户${props.oper !== 'add' ? ` - ${userName}` : ''}`"
|
||||
class="rounded-t"
|
||||
@@ -350,6 +374,15 @@ onMounted(() => {
|
||||
@click:append-inner="isConfirmPasswordVisible = !isConfirmPasswordVisible"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="userForm.nickname"
|
||||
density="comfortable"
|
||||
clearable
|
||||
label="昵称"
|
||||
placeholder="显示昵称,优先于用户名显示"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6" v-if="canControl">
|
||||
<VSelect
|
||||
v-model="userStatus"
|
||||
@@ -362,7 +395,7 @@ onMounted(() => {
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VDivider class="my-10">
|
||||
<span>消息账号绑定</span>
|
||||
<span>账号绑定</span>
|
||||
</VDivider>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -395,6 +428,9 @@ onMounted(() => {
|
||||
label="SynologyChat用户"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField v-model="userForm.settings.douban_userid" density="comfortable" clearable label="豆瓣用户" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
|
||||
323
src/components/dialog/WorkflowActionsDialog.vue
Normal file
323
src/components/dialog/WorkflowActionsDialog.vue
Normal file
@@ -0,0 +1,323 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { VueFlow, useVueFlow, type Connection, type GraphNode } from '@vue-flow/core'
|
||||
import { MiniMap } from '@vue-flow/minimap'
|
||||
import useDragAndDrop from '@core/utils/workflow'
|
||||
import { Workflow } from '@/api/types'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import api from '@/api'
|
||||
import WorkflowSidebar from '@/layouts/components/WorkflowSidebar.vue'
|
||||
import DropzoneBackground from '@/layouts/components/DropzoneBackground.vue'
|
||||
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
|
||||
|
||||
const { onConnect, addEdges, nodes, edges } = useVueFlow()
|
||||
|
||||
const { onDragOver, onDrop, onDragLeave, isDragOver } = useDragAndDrop()
|
||||
|
||||
// 连接事件
|
||||
onConnect((connection: Connection) => {
|
||||
// 双重校验
|
||||
if (!isValidConnection(connection)) {
|
||||
$toast.warning('非法连接:不能连接自身或同类型端口!')
|
||||
return
|
||||
}
|
||||
addEdges(connection)
|
||||
})
|
||||
|
||||
// 获取指定节点端口的类型(输入/输出)
|
||||
const getPortType = (node: GraphNode, handleId: string) => {
|
||||
// 检查是否是输入端口(对应 handleBounds.target)
|
||||
const isInput = node.handleBounds?.target?.some(h => h.id === handleId)
|
||||
if (isInput) return 'input'
|
||||
|
||||
// 检查是否是输出端口(对应 handleBounds.source)
|
||||
const isOutput = node.handleBounds?.source?.some(h => h.id === handleId)
|
||||
return isOutput ? 'output' : null
|
||||
}
|
||||
|
||||
// 校验连接是否合法
|
||||
const isValidConnection = (connection: Connection) => {
|
||||
// 获取连接的源节点和目标节点
|
||||
const sourceNode = nodes.value.find(n => n.id === connection.source)
|
||||
const targetNode = nodes.value.find(n => n.id === connection.target)
|
||||
|
||||
if (!sourceNode || !targetNode) return false
|
||||
|
||||
// 获取端口类型
|
||||
const sourcePortType = getPortType(sourceNode, connection.sourceHandle!)
|
||||
const targetPortType = getPortType(targetNode, connection.targetHandle!)
|
||||
|
||||
/* 同时满足三个条件,才允许连接:
|
||||
* 1. 源端口是输出类型(output)
|
||||
* 2. 目标端口是输入类型(input)
|
||||
* 3. 不是同一节点的连接
|
||||
*/
|
||||
return sourcePortType === 'output' && targetPortType === 'input' && connection.source !== connection.target
|
||||
}
|
||||
|
||||
// 自定义节点类型
|
||||
const nodeTypes: Record<string, any> = ref({})
|
||||
|
||||
// 自动扫描目录下所有的 .vue 文件
|
||||
const components = import.meta.glob('../workflow/*Action.vue')
|
||||
|
||||
// 动态加载某个组件
|
||||
const loadComponent = async (componentName: string) => {
|
||||
const component = components[`../workflow/${componentName}.vue`]
|
||||
if (component) {
|
||||
return ((await component()) as any).default
|
||||
}
|
||||
throw new Error(`组件 ${componentName} 未找到`)
|
||||
}
|
||||
|
||||
// 将所有components中的组件加载到nodeTypes中
|
||||
for (const path in components) {
|
||||
const componentName = path.match(/\.\/workflow\/(.*).vue$/)?.[1]
|
||||
if (!componentName) {
|
||||
continue
|
||||
}
|
||||
loadComponent(componentName).then(component => {
|
||||
nodeTypes.value[componentName] = markRaw(component)
|
||||
})
|
||||
}
|
||||
|
||||
// 定义输入参数
|
||||
const props = defineProps({
|
||||
workflow: Object as PropType<Workflow>,
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close', 'save'])
|
||||
|
||||
// 站点编辑表单数据
|
||||
const workflowForm = ref<any>(props.workflow || {})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 导入代码对话框
|
||||
const importCodeDialog = ref(false)
|
||||
|
||||
// 调用API 编辑任务
|
||||
async function updateWorkflow() {
|
||||
// 更新节点和流程
|
||||
workflowForm.value.actions = nodes
|
||||
workflowForm.value.flows = edges
|
||||
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.put(`workflow/${workflowForm.value.id}`, workflowForm.value)
|
||||
if (result.success) {
|
||||
$toast.success(`保存任务流程成功!`)
|
||||
emit('save')
|
||||
} else {
|
||||
$toast.error(`保存任务流程失败:${result.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存导入的代码,直接覆盖原有值
|
||||
function saveCodeString(type: string, code: any) {
|
||||
try {
|
||||
if (code) {
|
||||
const codeObject = JSON.parse(code.value)
|
||||
if (type === 'workflow') {
|
||||
nodes.value = codeObject.actions || []
|
||||
edges.value = codeObject.flows || []
|
||||
}
|
||||
importCodeDialog.value = false
|
||||
$toast.success('导入成功!')
|
||||
}
|
||||
} catch (error) {
|
||||
$toast.error('导入失败!')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 分享工作流程
|
||||
function shareWorkflow() {
|
||||
const codeString = JSON.stringify({ actions: nodes.value, flows: edges.value })
|
||||
navigator.clipboard.writeText(codeString)
|
||||
$toast.success('任务流程代码已复制到剪贴板!')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.workflow) {
|
||||
nodes.value = props.workflow.actions ?? []
|
||||
edges.value = props.workflow.flows ?? []
|
||||
}
|
||||
})
|
||||
|
||||
// 判断是不是MACOS
|
||||
const isMacOS = computed(() => {
|
||||
return /Macintosh|MacIntel|MacPPC|Mac68K/.test(navigator.userAgent)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog scrollable fullscreen :scrim="false" transition="dialog-bottom-transition">
|
||||
<VCard>
|
||||
<!-- Toolbar -->
|
||||
<div>
|
||||
<VToolbar color="primary">
|
||||
<VToolbarItems>
|
||||
<VBtn icon @click="emit('close')" class="ms-3">
|
||||
<VIcon size="large" color="white" icon="mdi-close" />
|
||||
</VBtn>
|
||||
</VToolbarItems>
|
||||
<VToolbarTitle> 编辑流程 - {{ workflow?.name }} </VToolbarTitle>
|
||||
<VToolbarItems>
|
||||
<VBtn icon @click="importCodeDialog = true">
|
||||
<VIcon size="large" color="white" icon="mdi-import" />
|
||||
</VBtn>
|
||||
<VBtn icon @click="shareWorkflow">
|
||||
<VIcon size="large" color="white" icon="mdi-share" />
|
||||
</VBtn>
|
||||
<VBtn icon @click="updateWorkflow" class="mx-5">
|
||||
<VIcon size="large" color="white" icon="mdi-content-save" />
|
||||
</VBtn>
|
||||
</VToolbarItems>
|
||||
</VToolbar>
|
||||
</div>
|
||||
<VDivider />
|
||||
<VCardText class="px-0 py-0">
|
||||
<div class="dnd-flow" @drop="onDrop">
|
||||
<VueFlow
|
||||
:nodes="nodes"
|
||||
:edges="edges"
|
||||
:nodeTypes="nodeTypes"
|
||||
:is-valid-connection="isValidConnection"
|
||||
:default-edge-options="{ type: 'animation', animated: true }"
|
||||
:edge-updater-radius="10"
|
||||
@dragover="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
:delete-key-code="isMacOS ? 'Backspace' : 'Delete'"
|
||||
auto-connect
|
||||
>
|
||||
<MiniMap />
|
||||
<DropzoneBackground
|
||||
:style="{
|
||||
backgroundColor: isDragOver ? '#e7f3ff' : 'transparent',
|
||||
transition: 'background-color 0.2s ease',
|
||||
}"
|
||||
>
|
||||
</DropzoneBackground>
|
||||
</VueFlow>
|
||||
<WorkflowSidebar />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<ImportCodeDialog
|
||||
v-if="importCodeDialog"
|
||||
v-model="importCodeDialog"
|
||||
title="导入任务流程"
|
||||
dataType="workflow"
|
||||
@close="importCodeDialog = false"
|
||||
@save="saveCodeString"
|
||||
/>
|
||||
</VDialog>
|
||||
</template>
|
||||
<style>
|
||||
@import '@vue-flow/core/dist/style.css';
|
||||
@import '@vue-flow/core/dist/theme-default.css';
|
||||
@import '@vue-flow/controls/dist/style.css';
|
||||
@import '@vue-flow/minimap/dist/style.css';
|
||||
@import '@vue-flow/node-resizer/dist/style.css';
|
||||
|
||||
.vue-flow__minimap {
|
||||
transform: scale(75%);
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
|
||||
.dnd-flow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.dnd-flow aside {
|
||||
background: #10b981bf;
|
||||
border-inline-end: 1px solid #eee;
|
||||
box-shadow: 0 5px 10px 0 rgba(0, 0, 0, 30%);
|
||||
box-shadow: 0 5px 10px #0000004d;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding-block: 15px;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.dnd-flow aside .nodes > * {
|
||||
box-shadow: 5px 5px 10px 2px rgba(0, 0, 0, 25%);
|
||||
box-shadow: 5px 5px 10px 2px #00000040;
|
||||
cursor: grab;
|
||||
font-weight: 500;
|
||||
margin-block-end: 10px;
|
||||
}
|
||||
|
||||
.dnd-flow aside .description {
|
||||
margin-block-end: 10px;
|
||||
}
|
||||
|
||||
.dnd-flow .vue-flow-wrapper {
|
||||
flex-grow: 1;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
@media screen and (width >= 640px) {
|
||||
.dnd-flow {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.dnd-flow aside {
|
||||
max-inline-size: 25%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width <= 639px) {
|
||||
.dnd-flow aside .nodes {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.dropzone-background {
|
||||
position: relative;
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.dropzone-background .overlay {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vue-flow__handle {
|
||||
border-radius: 4px;
|
||||
block-size: 24px;
|
||||
inline-size: 8px;
|
||||
}
|
||||
|
||||
.vue-flow__edge-path,
|
||||
.vue-flow__connection-path {
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.vue-flow__handle-left {
|
||||
background-color: rgb(var(--v-theme-info));
|
||||
}
|
||||
|
||||
.vue-flow__handle-right {
|
||||
background-color: rgb(var(--v-theme-error));
|
||||
}
|
||||
</style>
|
||||
133
src/components/dialog/WorkflowAddEditDialog.vue
Normal file
133
src/components/dialog/WorkflowAddEditDialog.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import type { Workflow } from '@/api/types'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import { requiredValidator } from '@/@validators'
|
||||
import api from '@/api'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
// 任务信息
|
||||
workflow: Object as PropType<Workflow>,
|
||||
})
|
||||
|
||||
// 新增或修改字样
|
||||
const title = computed(() => (props.workflow ? '编辑' : '创建'))
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 注册事件
|
||||
const emit = defineEmits(['save', 'remove', 'close'])
|
||||
|
||||
// 站点编辑表单数据
|
||||
const workflowForm = ref<Workflow>(
|
||||
props.workflow || {
|
||||
name: undefined,
|
||||
timer: undefined,
|
||||
description: undefined,
|
||||
state: 'P',
|
||||
run_count: 0,
|
||||
},
|
||||
)
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 调用API 新增任务
|
||||
async function addWorkflow() {
|
||||
if (!workflowForm.value.name || !workflowForm.value.timer) {
|
||||
$toast.error('请填写完整信息!')
|
||||
return
|
||||
}
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.post('workflow/', workflowForm.value)
|
||||
if (result.success) {
|
||||
$toast.success(`创建任务成功,请编辑流程!`)
|
||||
emit('save')
|
||||
} else {
|
||||
$toast.error(`创建任务失败:${result.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
doneNProgress()
|
||||
}
|
||||
|
||||
// 调用API 编辑任务
|
||||
async function editWorkflow() {
|
||||
if (!workflowForm.value.name || !workflowForm.value.timer) {
|
||||
$toast.error('请填写完整信息!')
|
||||
return
|
||||
}
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.put(`workflow/${workflowForm.value.id}`, workflowForm.value)
|
||||
if (result.success) {
|
||||
$toast.success(`修改任务成功!`)
|
||||
emit('save')
|
||||
} else {
|
||||
$toast.error(`修改任务失败:${result.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
doneNProgress()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog scrollable :close-on-back="false" eager max-width="30rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard :title="`${title}任务`" class="rounded-t">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="() => {}">
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="workflowForm.name"
|
||||
label="别名"
|
||||
:rules="[requiredValidator]"
|
||||
persistent-hint
|
||||
hint="任务名称"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VCronField
|
||||
v-model="workflowForm.timer"
|
||||
label="定时"
|
||||
:rules="[requiredValidator]"
|
||||
placeholder="5位cron表达式"
|
||||
persistent-hint
|
||||
hint="任务执行周期"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextarea v-model="workflowForm.description" label="任务描述" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
v-if="workflow"
|
||||
block
|
||||
color="primary"
|
||||
variant="elevated"
|
||||
@click="editWorkflow"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="px-5"
|
||||
>
|
||||
保存
|
||||
</VBtn>
|
||||
<VBtn v-else block color="primary" variant="elevated" @click="addWorkflow" prepend-icon="mdi-plus" class="px-5">
|
||||
创建
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -6,7 +6,7 @@ const attrs = useAttrs()
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '* * * * *',
|
||||
default: '/',
|
||||
},
|
||||
storage: {
|
||||
type: String,
|
||||
|
||||
@@ -14,9 +14,6 @@ import MediaInfoDialog from '../dialog/MediaInfoDialog.vue'
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// APP
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
|
||||
// 输入参数
|
||||
const inProps = defineProps({
|
||||
icons: Object,
|
||||
@@ -32,10 +29,20 @@ const inProps = defineProps({
|
||||
required: true,
|
||||
},
|
||||
sort: String,
|
||||
listStyle: String,
|
||||
showTree: Boolean,
|
||||
})
|
||||
|
||||
// 对外事件
|
||||
const emit = defineEmits(['loading', 'pathchanged', 'refreshed', 'filedeleted', 'renamed'])
|
||||
const emit = defineEmits([
|
||||
'loading',
|
||||
'pathchanged',
|
||||
'refreshed',
|
||||
'filedeleted',
|
||||
'renamed',
|
||||
'items-updated',
|
||||
'switch-tree',
|
||||
])
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
@@ -112,13 +119,6 @@ const transferItems = ref<FileItem[]>([])
|
||||
// 当前图片地址
|
||||
const currentImgLink = ref('')
|
||||
|
||||
// 大小控制
|
||||
const scrollStyle = computed(() => {
|
||||
return appMode
|
||||
? 'height: calc(100vh - 15.5rem - env(safe-area-inset-bottom) - 3.5rem)'
|
||||
: 'height: calc(100vh - 14.5rem - env(safe-area-inset-bottom)'
|
||||
})
|
||||
|
||||
// 是否为图片文件
|
||||
const isImage = computed(() => {
|
||||
const ext = inProps.item.path?.split('.').pop()?.toLowerCase()
|
||||
@@ -149,6 +149,9 @@ async function list_files() {
|
||||
items.value = (await inProps.axios.request(config)) ?? []
|
||||
emit('loading', false)
|
||||
loading.value = false
|
||||
|
||||
// 通知父组件文件列表更新
|
||||
emit('items-updated', items.value)
|
||||
}
|
||||
|
||||
// 删除项目
|
||||
@@ -375,6 +378,11 @@ function formatTime(timestape: number) {
|
||||
return new Date(timestape * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
// 切换文件树显示
|
||||
function switchFileTree(state: boolean) {
|
||||
emit('switch-tree', state)
|
||||
}
|
||||
|
||||
// 监听refreshPending变化
|
||||
watch(
|
||||
() => inProps.refreshpending,
|
||||
@@ -539,8 +547,12 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="d-flex flex-column">
|
||||
<VCard class="d-flex flex-column w-full h-full rounded-t-0" :class="{ 'rounded-s-0': showTree }">
|
||||
<VToolbar v-if="!loading" density="compact" flat color="gray">
|
||||
<IconBtn v-if="display.mdAndUp.value">
|
||||
<VIcon v-if="showTree" icon="mdi-file-tree" @click="switchFileTree(false)" />
|
||||
<VIcon v-else icon="mdi-file-tree-outline" @click="switchFileTree(true)" />
|
||||
</IconBtn>
|
||||
<VTextField
|
||||
v-if="!isFile"
|
||||
v-model="filter"
|
||||
@@ -551,7 +563,7 @@ onMounted(() => {
|
||||
placeholder="搜索 ..."
|
||||
prepend-inner-icon="mdi-filter-outline"
|
||||
class="me-2"
|
||||
rounded="0"
|
||||
rounded
|
||||
/>
|
||||
<VSpacer v-if="isFile" />
|
||||
<IconBtn v-if="!isFile" @click="changeSelectMode">
|
||||
@@ -605,7 +617,7 @@ onMounted(() => {
|
||||
<!-- 目录和文件列表 -->
|
||||
<VCardText v-else-if="dirs.length || files.length" class="p-0">
|
||||
<VList subheader>
|
||||
<VVirtualScroll :items="[...dirs, ...files]" :style="scrollStyle">
|
||||
<VVirtualScroll :items="[...dirs, ...files]" :style="listStyle">
|
||||
<template #default="{ item }">
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
@@ -619,7 +631,7 @@ onMounted(() => {
|
||||
v-if="inProps.icons && item.extension"
|
||||
:icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other"
|
||||
/>
|
||||
<VIcon v-else-if="item.type == 'dir'" icon="mdi-folder-outline" />
|
||||
<VIcon v-else-if="item.type == 'dir'" icon="mdi-folder" />
|
||||
<VIcon v-else icon="mdi-file-outline" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
486
src/components/filebrowser/FileNavigator.vue
Normal file
486
src/components/filebrowser/FileNavigator.vue
Normal file
@@ -0,0 +1,486 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue'
|
||||
import type { FileItem } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import type { Axios, AxiosRequestConfig } from 'axios'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
storage: {
|
||||
type: String,
|
||||
default: 'local',
|
||||
},
|
||||
currentPath: {
|
||||
type: String,
|
||||
default: '/',
|
||||
},
|
||||
items: {
|
||||
type: Array as PropType<FileItem[]>,
|
||||
default: () => [],
|
||||
},
|
||||
endpoints: Object,
|
||||
axios: {
|
||||
type: Object as PropType<Axios>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 对外事件
|
||||
const emit = defineEmits(['navigate'])
|
||||
|
||||
// 树形节点缓存
|
||||
const treeCache = ref<{ [key: string]: FileItem[] }>({})
|
||||
|
||||
// 展开的文件夹
|
||||
const expandedFolders = ref<string[]>([])
|
||||
|
||||
// 是否正在加载
|
||||
const loading = ref<{ [key: string]: boolean }>({})
|
||||
|
||||
// 点击目录
|
||||
function handleFolderClick(item: FileItem) {
|
||||
emit('navigate', item)
|
||||
}
|
||||
|
||||
// 切换文件夹展开状态
|
||||
async function toggleFolder(path: string) {
|
||||
const index = expandedFolders.value.indexOf(path)
|
||||
if (index >= 0) {
|
||||
// 折叠文件夹
|
||||
expandedFolders.value.splice(index, 1)
|
||||
} else {
|
||||
// 展开文件夹
|
||||
expandedFolders.value.push(path)
|
||||
// 如果缓存中没有此目录内容,加载它
|
||||
if (!treeCache.value[path]) {
|
||||
await loadSubdirectories(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 判断文件夹是否展开
|
||||
function isFolderExpanded(path: string) {
|
||||
return expandedFolders.value.includes(path)
|
||||
}
|
||||
|
||||
// 渲染文件夹图标
|
||||
function renderFolderIcon(isExpanded: boolean) {
|
||||
if (isExpanded) {
|
||||
return 'mdi-folder-open'
|
||||
}
|
||||
return 'mdi-folder'
|
||||
}
|
||||
|
||||
// 加载子目录
|
||||
async function loadSubdirectories(path: string) {
|
||||
// 如果已经在加载中或已有缓存,跳过
|
||||
if (loading.value[path] || treeCache.value[path]) return
|
||||
|
||||
// 标记为加载中
|
||||
loading.value[path] = true
|
||||
|
||||
try {
|
||||
// 构建假的文件项以加载目录内容
|
||||
const fakeItem: FileItem = {
|
||||
storage: props.storage,
|
||||
type: 'dir',
|
||||
name: path.split('/').pop() || '/',
|
||||
path: path,
|
||||
}
|
||||
|
||||
// 调用API加载目录内容
|
||||
const url = props.endpoints?.list.url.replace(/{sort}/g, 'name')
|
||||
|
||||
const config: AxiosRequestConfig<FileItem> = {
|
||||
url,
|
||||
method: props.endpoints?.list.method || 'get',
|
||||
data: fakeItem,
|
||||
}
|
||||
|
||||
const result = await props.axios?.request(config)
|
||||
if (result && Array.isArray(result)) {
|
||||
// 过滤出目录项
|
||||
const dirs = result.filter(item => item.type === 'dir')
|
||||
|
||||
// 缓存目录内容
|
||||
treeCache.value[path] = dirs
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载目录失败:', path, error)
|
||||
} finally {
|
||||
// 取消加载状态
|
||||
loading.value[path] = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始加载根目录
|
||||
async function loadRootDirectories() {
|
||||
await loadSubdirectories('/')
|
||||
}
|
||||
|
||||
// 获取目录层级深度
|
||||
function getDirectoryDepth(path: string) {
|
||||
return path.split('/').filter(p => p).length
|
||||
}
|
||||
|
||||
// 检索所有目录节点
|
||||
function getAllDirectories() {
|
||||
const allDirs: { dir: FileItem; level: number; parentPath: string }[] = []
|
||||
|
||||
// 添加根目录的子目录
|
||||
if (treeCache.value['/']) {
|
||||
treeCache.value['/'].forEach(dir => {
|
||||
allDirs.push({ dir, level: 0, parentPath: '/' })
|
||||
addSubdirectories(dir.path || '', 1, allDirs)
|
||||
})
|
||||
}
|
||||
|
||||
return allDirs
|
||||
}
|
||||
|
||||
// 递归添加子目录
|
||||
function addSubdirectories(
|
||||
parentPath: string,
|
||||
level: number,
|
||||
result: { dir: FileItem; level: number; parentPath: string }[],
|
||||
) {
|
||||
if (treeCache.value[parentPath]) {
|
||||
treeCache.value[parentPath].forEach(dir => {
|
||||
result.push({ dir, level, parentPath })
|
||||
if (isFolderExpanded(dir.path || '')) {
|
||||
addSubdirectories(dir.path || '', level + 1, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 监听当前路径变化,自动展开当前路径
|
||||
watch(
|
||||
() => props.currentPath,
|
||||
async newPath => {
|
||||
if (!newPath) return
|
||||
|
||||
// 如果当前路径不是根目录,自动展开父目录
|
||||
if (newPath !== '/') {
|
||||
const parts = newPath.split('/').filter(p => p)
|
||||
let currentPath = ''
|
||||
|
||||
// 展开到当前路径的每一层
|
||||
for (const part of parts) {
|
||||
currentPath += '/' + part
|
||||
|
||||
// 如果该路径未展开,则展开它
|
||||
if (!expandedFolders.value.includes(currentPath)) {
|
||||
expandedFolders.value.push(currentPath)
|
||||
|
||||
// 确保子目录已加载
|
||||
if (!treeCache.value[currentPath]) {
|
||||
await loadSubdirectories(currentPath)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有上一级目录,确保它已加载
|
||||
const parentPath = currentPath.substring(0, currentPath.lastIndexOf('/')) || '/'
|
||||
if (!treeCache.value[parentPath]) {
|
||||
await loadSubdirectories(parentPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 监听目录变化,缓存当前目录的内容
|
||||
watch(
|
||||
() => props.items,
|
||||
newItems => {
|
||||
if (newItems && newItems.length > 0) {
|
||||
// 过滤出目录项
|
||||
const dirs = newItems.filter(item => item.type === 'dir')
|
||||
|
||||
// 缓存当前目录内容
|
||||
treeCache.value[props.currentPath || '/'] = dirs
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 是否为移动端
|
||||
const isMobile = computed(() => {
|
||||
return display.smAndDown.value
|
||||
})
|
||||
|
||||
// 可用的根目录列表
|
||||
const rootDirectories = computed(() => {
|
||||
return treeCache.value['/'] || []
|
||||
})
|
||||
|
||||
// 扁平化的目录树
|
||||
const flattenedDirectories = computed(() => {
|
||||
return getAllDirectories()
|
||||
})
|
||||
|
||||
// 组件挂载时初始加载
|
||||
onMounted(async () => {
|
||||
await loadRootDirectories()
|
||||
})
|
||||
|
||||
// 检查路径是否为指定目录的子目录或后代
|
||||
function isChildOrDescendant(path: string, ancestorPath: string) {
|
||||
if (!path || !ancestorPath) return false
|
||||
if (ancestorPath === '/') return true
|
||||
|
||||
// 确保路径以斜杠结尾,便于比较
|
||||
const normalizedPath = path.endsWith('/') ? path : path + '/'
|
||||
const normalizedAncestorPath = ancestorPath.endsWith('/') ? ancestorPath : ancestorPath + '/'
|
||||
|
||||
// 检查路径是否以祖先路径开头,但不是祖先路径本身
|
||||
return normalizedPath.startsWith(normalizedAncestorPath) && normalizedPath !== normalizedAncestorPath
|
||||
}
|
||||
|
||||
// 计算目录相对于其祖先的缩进级别
|
||||
function getIndentLevel(path: string, ancestorPath: string) {
|
||||
if (!path || !ancestorPath) return 0
|
||||
|
||||
// 根目录特殊处理
|
||||
if (ancestorPath === '/') {
|
||||
return path.split('/').filter(p => p).length - 1
|
||||
}
|
||||
|
||||
// 计算路径中斜杠的数量差异
|
||||
const pathParts = path.split('/').filter(p => p).length
|
||||
const ancestorParts = ancestorPath.split('/').filter(p => p).length
|
||||
|
||||
return pathParts - ancestorParts
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="file-navigator rounded-e-0 rounded-t-0" v-if="!isMobile">
|
||||
<div class="tree-container">
|
||||
<!-- 根目录项 -->
|
||||
<div
|
||||
class="tree-item root-item"
|
||||
:class="{ 'active': currentPath === '/' }"
|
||||
@click="
|
||||
handleFolderClick({
|
||||
storage: storage,
|
||||
type: 'dir',
|
||||
name: '/',
|
||||
path: '/',
|
||||
})
|
||||
"
|
||||
>
|
||||
<div class="folder-content">
|
||||
<VIcon icon="mdi-home" class="me-2" color="primary" />
|
||||
<span>根目录</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载根目录 -->
|
||||
<div v-if="loading['/']" class="tree-loading">
|
||||
<VProgressCircular indeterminate size="24" color="primary" class="ma-2" />
|
||||
<span>加载目录结构...</span>
|
||||
</div>
|
||||
|
||||
<!-- 目录树结构 -->
|
||||
<template v-else>
|
||||
<!-- 一级目录(根目录下的目录) -->
|
||||
<div v-for="directory in rootDirectories" :key="directory.path" class="tree-item-container">
|
||||
<!-- 目录项 -->
|
||||
<div class="tree-item" :class="{ 'active': currentPath === directory.path }">
|
||||
<div class="folder-toggle" @click.stop="toggleFolder(directory.path || '')">
|
||||
<VProgressCircular
|
||||
v-if="loading[directory.path || '']"
|
||||
indeterminate
|
||||
size="14"
|
||||
width="2"
|
||||
color="primary"
|
||||
/>
|
||||
<VIcon
|
||||
v-else
|
||||
size="small"
|
||||
:icon="isFolderExpanded(directory.path || '') ? 'mdi-chevron-down' : 'mdi-chevron-right'"
|
||||
/>
|
||||
</div>
|
||||
<div class="folder-content" @click.stop="handleFolderClick(directory)">
|
||||
<VIcon
|
||||
size="small"
|
||||
:icon="renderFolderIcon(isFolderExpanded(directory.path || ''))"
|
||||
:color="currentPath === directory.path ? 'primary' : 'amber-darken-1'"
|
||||
class="me-1"
|
||||
/>
|
||||
<VTooltip :disabled="directory.name.length <= 18">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<span class="folder-name" v-bind="tooltipProps">
|
||||
{{ directory.name }}
|
||||
</span>
|
||||
</template>
|
||||
{{ directory.name }}
|
||||
</VTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 子目录容器 - 如果该目录被展开,显示其所有子目录 -->
|
||||
<div v-if="isFolderExpanded(directory.path || '')">
|
||||
<!-- 加载中状态 -->
|
||||
<div v-if="loading[directory.path || '']" class="tree-loading pl-8">
|
||||
<VProgressCircular indeterminate size="14" color="primary" class="ma-2" />
|
||||
<span class="text-caption">加载中...</span>
|
||||
</div>
|
||||
|
||||
<!-- 所有层级的子目录列表 -->
|
||||
<div v-else>
|
||||
<!-- 遍历所有扁平化的目录列表,查找对应层级的目录 -->
|
||||
<div
|
||||
v-for="item in flattenedDirectories"
|
||||
:key="item.dir.path"
|
||||
v-show="isChildOrDescendant(item.dir.path || '', directory.path || '')"
|
||||
class="tree-item"
|
||||
:class="{ 'active': currentPath === item.dir.path }"
|
||||
:style="{ paddingLeft: 16 + getIndentLevel(item.dir.path || '', directory.path || '') * 12 + 'px' }"
|
||||
>
|
||||
<!-- 展开/折叠按钮 -->
|
||||
<div class="folder-toggle" @click.stop="toggleFolder(item.dir.path || '')">
|
||||
<VProgressCircular
|
||||
v-if="loading[item.dir.path || '']"
|
||||
indeterminate
|
||||
size="14"
|
||||
width="2"
|
||||
color="primary"
|
||||
/>
|
||||
<VIcon
|
||||
v-else
|
||||
size="small"
|
||||
:icon="isFolderExpanded(item.dir.path || '') ? 'mdi-chevron-down' : 'mdi-chevron-right'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 文件夹图标和名称 -->
|
||||
<div class="folder-content" @click.stop="handleFolderClick(item.dir)">
|
||||
<VIcon
|
||||
size="small"
|
||||
:icon="renderFolderIcon(isFolderExpanded(item.dir.path || ''))"
|
||||
:color="currentPath === item.dir.path ? 'primary' : 'amber-darken-1'"
|
||||
class="me-1"
|
||||
/>
|
||||
<VTooltip :disabled="item.dir.name.length <= 18">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<span class="folder-name" v-bind="tooltipProps">
|
||||
{{ item.dir.name }}
|
||||
</span>
|
||||
</template>
|
||||
{{ item.dir.name }}
|
||||
</VTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.file-navigator {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
background: rgb(var(--v-table-header-background));
|
||||
block-size: 100%;
|
||||
border-end-start-radius: 12px;
|
||||
inline-size: 240px;
|
||||
}
|
||||
|
||||
.navigator-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-block-end: 1px solid rgba(0, 0, 0, 8%);
|
||||
padding-block: 12px;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.tree-container {
|
||||
overflow: hidden auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tree-item-container {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.tree-item {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
max-inline-size: 100%;
|
||||
min-inline-size: 100%;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.folder-toggle {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
block-size: 16px;
|
||||
inline-size: 16px;
|
||||
margin-inline-end: 4px;
|
||||
padding-block: 6px;
|
||||
padding-inline: 12px 0;
|
||||
}
|
||||
|
||||
.folder-content {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
min-inline-size: 0;
|
||||
padding-block: 6px;
|
||||
padding-inline: 8px 16px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.root-item {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.folder-name {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
max-inline-size: 150px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subdirectory-container {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.tree-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
padding-block: 4px;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.pl-8 {
|
||||
padding-inline-start: 20px !important;
|
||||
}
|
||||
</style>
|
||||
@@ -115,7 +115,7 @@ const sortIcon = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VToolbar flat dense>
|
||||
<VToolbar flat dense class="rounded-t-lg border-b overflow-hidden">
|
||||
<VToolbarItems class="overflow-hidden">
|
||||
<VMenu v-if="inProps.storages?.length || 0 > 1" offset-y>
|
||||
<template #activator="{ props }">
|
||||
|
||||
7
src/components/misc/FilterOption.vue
Normal file
7
src/components/misc/FilterOption.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ title: string }>()
|
||||
</script>
|
||||
<template>
|
||||
<VListSubheader>{{ title }}</VListSubheader>
|
||||
<VListItem><slot /></VListItem>
|
||||
</template>
|
||||
@@ -6,10 +6,21 @@ import { type PropType } from 'vue'
|
||||
const elementProps = defineProps({
|
||||
config: Object as PropType<RenderProps>,
|
||||
})
|
||||
// key
|
||||
const componentKey = ref(0)
|
||||
|
||||
onActivated(() => {
|
||||
componentKey.value++
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Component :is="elementProps.config?.component" v-if="!elementProps.config?.html" v-bind="elementProps.config?.props">
|
||||
<Component
|
||||
:key="componentKey"
|
||||
:is="elementProps.config?.component"
|
||||
v-if="!elementProps.config?.html"
|
||||
v-bind="elementProps.config?.props"
|
||||
>
|
||||
{{ elementProps.config?.text }}
|
||||
<template v-for="(content, name) in elementProps.config?.slots || []" :key="name" v-slot:[name]="{ _props }">
|
||||
<slot :name="name" v-bind="_props">
|
||||
@@ -23,6 +34,7 @@ const elementProps = defineProps({
|
||||
/>
|
||||
</Component>
|
||||
<Component
|
||||
:key="componentKey"
|
||||
:is="elementProps.config?.component"
|
||||
v-if="elementProps.config?.html"
|
||||
v-bind="elementProps.config?.props"
|
||||
|
||||
@@ -16,6 +16,9 @@ defineProps<{
|
||||
const parseProps = (rawProps: Record<string, any>, model: Record<string, any>) => {
|
||||
const parsedProps: Record<string, any> = {}
|
||||
|
||||
const isExpression = (value: string) => value.startsWith('{{') && value.endsWith('}}')
|
||||
const extractExpression = (value: string) => value.slice(2, -2).trim()
|
||||
|
||||
for (const [key, value] of Object.entries(rawProps)) {
|
||||
if (key === 'modelvalue') {
|
||||
// 将 modelvalue 转换为 v-model:value 的形式
|
||||
@@ -23,22 +26,63 @@ const parseProps = (rawProps: Record<string, any>, model: Record<string, any>) =
|
||||
parsedProps['onUpdate:value'] = (newValue: any) => {
|
||||
model[value] = newValue
|
||||
}
|
||||
} else if (key === 'model') {
|
||||
} else if (['model', 'v-model'].includes(key)) {
|
||||
// 处理 v-model
|
||||
parsedProps['modelValue'] = model[value]
|
||||
parsedProps['onUpdate:modelValue'] = (newValue: any) => {
|
||||
model[value] = newValue
|
||||
}
|
||||
} else if (key.startsWith('model:')) {
|
||||
} else if (['show', 'v-show'].includes(key)) {
|
||||
// 处理 v-show,实现显示隐藏
|
||||
const expression = isExpression(value) ? extractExpression(value) : value
|
||||
const isVisible = new Function('model', `with(model) { return ${expression} }`)(model)
|
||||
// 动态设置 style.display
|
||||
if (!parsedProps.style) {
|
||||
parsedProps.style = {}
|
||||
}
|
||||
parsedProps.style.display = isVisible ? '' : 'none'
|
||||
} else if (key.startsWith('model:') || key.startsWith('v-model:')) {
|
||||
// 处理 v-model:<prop>
|
||||
const propName = key.replace('model:', '')
|
||||
const propName = key.split(':')[1]
|
||||
parsedProps[propName] = model[value]
|
||||
parsedProps[`onUpdate:${propName}`] = (newValue: any) => {
|
||||
model[value] = newValue
|
||||
}
|
||||
} else if (key.startsWith('on')) {
|
||||
// 处理事件监听,值是函数的代码 function xxx(e) { ... }
|
||||
if (typeof value === 'string') {
|
||||
// 创建动态函数并绑定model上下文
|
||||
const handler = new Function(
|
||||
'model',
|
||||
'event',
|
||||
`
|
||||
try {
|
||||
with(model) {
|
||||
return (${value})(event);
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('事件处理函数执行错误:', e);
|
||||
}
|
||||
`,
|
||||
)
|
||||
// 包装事件处理器,保持vue事件参数传递特性
|
||||
parsedProps[key] = (...args: any[]) => {
|
||||
const [event] = args
|
||||
return handler(model, event)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 普通属性直接赋值
|
||||
parsedProps[key] = typeof value === 'string' && value in model ? model[value] : value
|
||||
// 如果是表达式,需要绑定
|
||||
if (typeof value === 'string' && isExpression(value)) {
|
||||
const expression = extractExpression(value)
|
||||
parsedProps[key] = new Function('model', `with(model) { return ${expression} }`)(model)
|
||||
} else if (typeof value === 'string' && value in model) {
|
||||
// 如果是数据模型的属性,直接绑定
|
||||
parsedProps[key] = model[value]
|
||||
} else {
|
||||
// 其他情况直接赋值
|
||||
parsedProps[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ const display = useDisplay()
|
||||
|
||||
// 元素
|
||||
const slideview_content = ref()
|
||||
// 分页切换状态
|
||||
const sliderContainer = ref()
|
||||
// 分页切换状态: 0-左边不可用 1-两边可用 2-右边不可用 3-两边都不可用
|
||||
const disabled = ref(0)
|
||||
// 记录滚动值
|
||||
const slideview_scrollLeft = ref(0)
|
||||
@@ -21,6 +22,10 @@ let card_width: number
|
||||
let card_max: number
|
||||
// 当前定位
|
||||
let card_current: number
|
||||
// 是否鼠标悬停在容器上
|
||||
const isHovering = ref(false)
|
||||
// 获取传入的链接地址
|
||||
const props: any = inject('rankingPropsKey', { linkurl: '', title: '' })
|
||||
|
||||
// 分页切换
|
||||
function slideNext(next: boolean) {
|
||||
@@ -30,12 +35,10 @@ function slideNext(next: boolean) {
|
||||
run_to_left_px = card_index * card_width
|
||||
if (run_to_left_px >= slideview_content.value.scrollWidth - slideview_content.value.clientWidth)
|
||||
run_to_left_px = slideview_content.value.scrollWidth - slideview_content.value.clientWidth
|
||||
// console.log(`最多显示: ${card_max} 当前起点: ${card_current} 目标起点: ${card_index} 卡片宽度: ${card_width}`)
|
||||
} else {
|
||||
const card_index = card_current - card_max
|
||||
run_to_left_px = card_index * card_width
|
||||
if (run_to_left_px <= 0) run_to_left_px = 0
|
||||
// console.log(`最多显示: ${card_max} 当前起点: ${card_current} 目标起点: ${card_index} 卡片宽度: ${card_width}`)
|
||||
}
|
||||
slideview_content.value.scrollTo({
|
||||
top: 0,
|
||||
@@ -46,6 +49,7 @@ function slideNext(next: boolean) {
|
||||
|
||||
// 计算最大显示数量
|
||||
function countMaxNumber() {
|
||||
if (!slideview_content.value || !slideview_content.value.firstElementChild) return
|
||||
slide_card_length = slideview_content.value.children.length
|
||||
card_width = slideview_content.value.firstElementChild.getBoundingClientRect().width
|
||||
slide_gap_px = slideview_content.value.scrollWidth / slide_card_length - card_width
|
||||
@@ -71,6 +75,21 @@ function countDisabled() {
|
||||
else disabled.value = 1
|
||||
}
|
||||
|
||||
// 处理鼠标进入
|
||||
function handleMouseEnter() {
|
||||
isHovering.value = true
|
||||
}
|
||||
|
||||
// 处理鼠标离开
|
||||
function handleMouseLeave() {
|
||||
isHovering.value = false
|
||||
}
|
||||
|
||||
// 检测是否有足够内容可显示
|
||||
const hasEnoughContent = computed(() => {
|
||||
return slide_card_length > card_max
|
||||
})
|
||||
|
||||
// 组件加载完成
|
||||
onMounted(() => {
|
||||
// 初次获取元素参数
|
||||
@@ -78,61 +97,222 @@ onMounted(() => {
|
||||
// 窗口大小发生改变时
|
||||
window.addEventListener('resize', countMaxNumber)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 卸载事件
|
||||
window.removeEventListener('resize', countMaxNumber)
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
if (slideview_scrollLeft.value !== 0) {
|
||||
// console.log(`onActivated: to_scrollLeft, ${slideview_scrollLeft.value}`)
|
||||
slideview_content.value.scrollLeft = slideview_scrollLeft.value
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-between mt-3">
|
||||
<slot name="title">
|
||||
<SlideViewTitle />
|
||||
</slot>
|
||||
<div v-if="disabled !== 3 && display.mdAndUp.value" class="me-1 d-flex">
|
||||
<VBtn
|
||||
class="rounded-circle"
|
||||
variant="text"
|
||||
icon="mdi-chevron-left"
|
||||
color="grey"
|
||||
:disabled="disabled === 0"
|
||||
@click="slideNext(false)"
|
||||
/>
|
||||
<VBtn
|
||||
class="rounded-circle"
|
||||
variant="text"
|
||||
icon="mdi-chevron-right"
|
||||
color="grey"
|
||||
:disabled="disabled === 2"
|
||||
@click="slideNext(true)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="slideview_content"
|
||||
class="slideview_content grid grid-rows-1 grid-flow-col justify-start gap-4 p-3"
|
||||
tabindex="0"
|
||||
@scroll="countDisabled"
|
||||
<div
|
||||
ref="sliderContainer"
|
||||
class="slider-container"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<slot name="content" />
|
||||
<div class="slider-header">
|
||||
<slot name="title">
|
||||
<SlideViewTitle />
|
||||
</slot>
|
||||
|
||||
<!-- 查看全部按钮 -->
|
||||
<RouterLink
|
||||
v-if="props.linkurl"
|
||||
:to="props.linkurl"
|
||||
class="view-all-button"
|
||||
>
|
||||
<span>全部</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" class="arrow-svg">
|
||||
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="slider-content-wrapper">
|
||||
<div class="slider-content-container">
|
||||
<div
|
||||
ref="slideview_content"
|
||||
class="slider-content"
|
||||
tabindex="0"
|
||||
@scroll="countDisabled"
|
||||
>
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 左侧导航按钮 -->
|
||||
<button
|
||||
class="nav-button nav-button-left"
|
||||
@click.stop="slideNext(false)"
|
||||
v-show="isHovering && disabled !== 0 && disabled !== 3"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- 右侧导航按钮 -->
|
||||
<button
|
||||
class="nav-button nav-button-right"
|
||||
@click.stop="slideNext(true)"
|
||||
v-show="isHovering && disabled !== 2 && disabled !== 3"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.slideview_content {
|
||||
.slider-container {
|
||||
position: relative;
|
||||
margin-bottom: 24px;
|
||||
// 移除padding,按钮放置在外部
|
||||
}
|
||||
|
||||
.slider-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
padding: 0 8px;
|
||||
gap: 16px;
|
||||
|
||||
& > :first-child {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.view-all-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
background-color: rgba(var(--v-theme-primary), 0.1);
|
||||
padding: 4px 10px;
|
||||
border-radius: 16px;
|
||||
transition: all 0.25s ease;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.15);
|
||||
box-shadow: 0 2px 8px rgba(var(--v-theme-primary), 0.1);
|
||||
transform: translateY(-1px);
|
||||
|
||||
.arrow-svg {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.arrow-svg {
|
||||
transition: transform 0.3s ease;
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.slider-content-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.slider-content-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(var(--v-theme-surface), 0.9);
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
backdrop-filter: blur(5px);
|
||||
-webkit-backdrop-filter: blur(5px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 20;
|
||||
transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), background-color 0.3s ease, box-shadow 0.3s ease;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
|
||||
svg {
|
||||
fill: rgb(var(--v-theme-on-surface));
|
||||
opacity: 0.8;
|
||||
transition: all 0.3s ease;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-50%) scale(1.1);
|
||||
background-color: rgba(var(--v-theme-surface), 1);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.22);
|
||||
border-color: rgba(var(--v-theme-on-surface), 0.15);
|
||||
|
||||
svg {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-button-left {
|
||||
left: -19px; // 半径
|
||||
}
|
||||
|
||||
.nav-button-right {
|
||||
right: -19px; // 半径
|
||||
}
|
||||
|
||||
.slider-content {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
grid-auto-flow: column;
|
||||
justify-content: start;
|
||||
gap: 16px;
|
||||
padding: 8px 12px;
|
||||
overflow: scroll hidden !important;
|
||||
-ms-overflow-style: none !important;
|
||||
overscroll-behavior-x: contain !important;
|
||||
scrollbar-width: none !important;
|
||||
scroll-behavior: smooth;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.slideview_content::-webkit-scrollbar {
|
||||
display: none;
|
||||
.slider-container:hover .nav-button[style*="display: none;"] ~ .nav-button,
|
||||
.slider-container:hover .nav-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.nav-button[style*="display: none;"] {
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,10 +4,41 @@ const props: any = inject('rankingPropsKey')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ms-1">
|
||||
<RouterLink :to="props?.linkurl ? props?.linkurl : ''" class="slider-title">
|
||||
<span>{{ props?.title }}</span>
|
||||
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" />
|
||||
</RouterLink>
|
||||
<div class="title-wrapper">
|
||||
<div class="title-section">
|
||||
<div class="title-badge"></div>
|
||||
<h3 class="title-text">{{ props?.title }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.title-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.title-badge {
|
||||
width: 3px;
|
||||
height: 16px;
|
||||
background-color: rgb(var(--v-theme-primary));
|
||||
margin-right: 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: rgba(var(--v-theme-on-background), 0.95);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
73
src/components/workflow/AddDownloadAction.vue
Normal file
73
src/components/workflow/AddDownloadAction.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { DownloaderConf } from '@/api/types'
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
|
||||
defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 下载器选项
|
||||
const downloaderOptions = ref<{ title: string; value: string }[]>([])
|
||||
|
||||
// 加载所有下载器
|
||||
async function loadDownloaderSetting() {
|
||||
try {
|
||||
const downloaders: DownloaderConf[] = await api.get('download/clients')
|
||||
downloaderOptions.value = [
|
||||
{ title: '默认', value: '' },
|
||||
...downloaders.map((item: { name: any }) => ({
|
||||
title: item.name,
|
||||
value: item.name,
|
||||
})),
|
||||
]
|
||||
} catch (error) {
|
||||
console.error('加载下载器设置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDownloaderSetting()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard max-width="20rem">
|
||||
<Handle id="edge_in" type="target" :position="Position.Left" />
|
||||
<VCardItem>
|
||||
<template v-slot:prepend>
|
||||
<VAvatar>
|
||||
<VIcon icon="mdi-download-box-outline" size="x-large"></VIcon>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>添加下载</VCardTitle>
|
||||
<VCardSubtitle>根据资源列表添加下载任务</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VSelect v-model="data.downloader" :items="downloaderOptions" label="下载器" outlined dense />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="data.labels" label="标签" placeholder="多个使用,分隔" outlined dense />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VPathField v-model="data.save_path" storage="local" label="保存路径" clearable placeholder="留空自动" />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VSwitch v-model="data.only_lack" label="仅下载缺失的资源" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<Handle id="edge_out" type="source" :position="Position.Right" />
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
31
src/components/workflow/AddSubscribeAction.vue
Normal file
31
src/components/workflow/AddSubscribeAction.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
|
||||
defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard>
|
||||
<Handle id="edge_in" type="target" :position="Position.Left" />
|
||||
<VCardItem>
|
||||
<template v-slot:prepend>
|
||||
<VAvatar>
|
||||
<VIcon icon="mdi-star-check" size="x-large"></VIcon>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>添加订阅</VCardTitle>
|
||||
<VCardSubtitle>根据媒体列表添加订阅</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<Handle id="edge_out" type="source" :position="Position.Right" />
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
50
src/components/workflow/FetchDownloadsAction.vue
Normal file
50
src/components/workflow/FetchDownloadsAction.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
|
||||
defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard max-width="20rem">
|
||||
<Handle id="edge_in" type="target" :position="Position.Left" />
|
||||
<VCardItem>
|
||||
<template v-slot:prepend>
|
||||
<VAvatar>
|
||||
<VIcon icon="mdi-progress-download" size="x-large"></VIcon>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>获取下载任务</VCardTitle>
|
||||
<VCardSubtitle>获取下载队列中的任务状态</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VSwitch v-model="data.loop" label="循环执行" />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="data.loop_interval"
|
||||
:disabled="!data.loop"
|
||||
type="number"
|
||||
label="循环间隔 (秒)"
|
||||
outlined
|
||||
dense
|
||||
clearable
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<Handle id="edge_out" type="source" :position="Position.Right" />
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
156
src/components/workflow/FetchMediasAction.vue
Normal file
156
src/components/workflow/FetchMediasAction.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import api from '@/api'
|
||||
import { RecommendSource } from '@/api/types'
|
||||
|
||||
defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 内置榜单
|
||||
const innerList = [
|
||||
{
|
||||
'api_path': 'recommend/tmdb_trending',
|
||||
'name': '流行趋势',
|
||||
},
|
||||
{
|
||||
'api_path': 'recommend/douban_showing',
|
||||
'name': '正在热映',
|
||||
},
|
||||
{
|
||||
'api_path': 'recommend/bangumi_calendar',
|
||||
'name': 'Bangumi每日放送',
|
||||
},
|
||||
{
|
||||
'api_path': 'recommend/tmdb_movies',
|
||||
'name': 'TMDB热门电影',
|
||||
},
|
||||
{
|
||||
'api_path': 'recommend/tmdb_tvs?with_original_language=zh|en|ja|ko',
|
||||
'name': 'TMDB热门电视剧',
|
||||
},
|
||||
{
|
||||
'api_path': 'recommend/douban_movie_hot',
|
||||
'name': '豆瓣热门电影',
|
||||
},
|
||||
{
|
||||
'api_path': 'recommend/douban_tv_hot',
|
||||
'name': '豆瓣热门电视剧',
|
||||
},
|
||||
{
|
||||
'api_path': 'recommend/douban_tv_animation',
|
||||
'name': '豆瓣热门动漫',
|
||||
},
|
||||
{
|
||||
'api_path': 'recommend/douban_movies',
|
||||
'name': '豆瓣最新电影',
|
||||
},
|
||||
{
|
||||
'api_path': 'recommend/douban_tvs',
|
||||
'name': '豆瓣最新电视剧',
|
||||
},
|
||||
{
|
||||
'api_path': 'recommend/douban_movie_top250',
|
||||
'name': '豆瓣电影TOP250',
|
||||
},
|
||||
{
|
||||
'api_path': 'recommend/douban_tv_weekly_chinese',
|
||||
'name': '豆瓣国产剧集榜',
|
||||
},
|
||||
{
|
||||
'api_path': 'recommend/douban_tv_weekly_global',
|
||||
'name': '豆瓣全球剧集榜',
|
||||
},
|
||||
]
|
||||
|
||||
// 额外的数据源
|
||||
const extraRecommendSources = ref<RecommendSource[]>([])
|
||||
|
||||
// 加载额外的发现数据源
|
||||
async function loadExtraRecommendSources() {
|
||||
try {
|
||||
extraRecommendSources.value = await api.get('recommend/source')
|
||||
if (extraRecommendSources.value.length > 0) {
|
||||
innerList.push(
|
||||
...extraRecommendSources.value.map(source => ({
|
||||
api_path: source.api_path,
|
||||
name: source.name,
|
||||
})),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 来源类型下拉框
|
||||
const sourceTypeOptions = [
|
||||
{ value: 'ranking', title: '推荐榜单' },
|
||||
{ value: 'api', title: 'API' },
|
||||
]
|
||||
|
||||
// 计算下拉框
|
||||
const sourceOptions = computed(() => innerList.map(item => item.name))
|
||||
|
||||
onMounted(() => {
|
||||
loadExtraRecommendSources()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard max-width="20rem">
|
||||
<Handle id="edge_in" type="target" :position="Position.Left" />
|
||||
<VCardItem>
|
||||
<template v-slot:prepend>
|
||||
<VAvatar>
|
||||
<VIcon icon="mdi-multimedia" size="x-large"></VIcon>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>获取媒体数据</VCardTitle>
|
||||
<VCardSubtitle>获取榜单等媒体数据列表</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VSelect v-model="data.source_type" :items="sourceTypeOptions" label="来源" outlined dense />
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="data.source_type === 'ranking'">
|
||||
<VCol cols="12">
|
||||
<VSelect
|
||||
v-model="data.sources"
|
||||
:items="sourceOptions"
|
||||
label="选择榜单"
|
||||
chips
|
||||
multiple
|
||||
outlined
|
||||
dense
|
||||
clearable
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="data.api_path"
|
||||
label="API地址"
|
||||
placeholder="/api/v1/plugin/xxx/xxxx"
|
||||
outlined
|
||||
dense
|
||||
clearable
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<Handle id="edge_out" type="source" :position="Position.Right" />
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
51
src/components/workflow/FetchRssAction.vue
Normal file
51
src/components/workflow/FetchRssAction.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
|
||||
defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard max-width="20rem">
|
||||
<Handle id="edge_in" type="target" :position="Position.Left" />
|
||||
<VCardItem>
|
||||
<template v-slot:prepend>
|
||||
<VAvatar>
|
||||
<VIcon icon="mdi-rss" size="x-large"></VIcon>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>获取RSS资源</VCardTitle>
|
||||
<VCardSubtitle>订阅RSS地址获取资源</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="data.url" label="RSS地址" outlined dense clearable />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="data.ua" label="User-Agent" outlined dense clearable />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="data.timeout" type="number" label="超时时间" outlined dense clearable />
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VSwitch v-model="data.match_media" label="匹配媒体信息" />
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VSwitch v-model="data.proxy" label="使用代理" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<Handle id="edge_out" type="source" :position="Position.Right" />
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
118
src/components/workflow/FetchTorrentsAction.vue
Normal file
118
src/components/workflow/FetchTorrentsAction.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { Site } from '@/api/types'
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
|
||||
defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 电影/电视剧下拉框
|
||||
const typeOptions = ref([
|
||||
{
|
||||
title: '电影',
|
||||
value: '电影',
|
||||
},
|
||||
{
|
||||
title: '电视剧',
|
||||
value: '电视剧',
|
||||
},
|
||||
])
|
||||
|
||||
// 搜索方式下拉框
|
||||
const searchOptions = ref([
|
||||
{
|
||||
title: '名称',
|
||||
value: 'keyword',
|
||||
},
|
||||
{
|
||||
title: '媒体列表',
|
||||
value: 'media',
|
||||
},
|
||||
])
|
||||
|
||||
// 站点数据列表
|
||||
const siteList = ref<Site[]>([])
|
||||
|
||||
// 获取站点列表数据
|
||||
async function loadSites() {
|
||||
try {
|
||||
const data: Site[] = await api.get('site/rss')
|
||||
|
||||
// 过滤站点,只有启用的站点才显示
|
||||
siteList.value = data.filter(item => item.is_active)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 站点选项
|
||||
const siteOptions = computed(() => {
|
||||
return siteList.value.map(item => {
|
||||
return {
|
||||
title: item.name,
|
||||
value: item.id,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadSites()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard max-width="20rem">
|
||||
<Handle id="edge_in" type="target" :position="Position.Left" />
|
||||
<VCardItem>
|
||||
<template v-slot:prepend>
|
||||
<VAvatar>
|
||||
<VIcon icon="mdi-search-web" size="x-large"></VIcon>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>搜索站点资源</VCardTitle>
|
||||
<VCardSubtitle>搜索站点种子资源列表</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VSelect v-model="data.search_type" label="搜索方式" :items="searchOptions" outlined dense />
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="data.search_type === 'keyword'">
|
||||
<VCol cols="6">
|
||||
<VTextField v-model="data.name" label="名称" outlined dense />
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField v-model="data.year" label="年份" outlined dense />
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VSelect v-model="data.type" label="类型" :items="typeOptions" outlined dense />
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField v-model="data.season" type="number" label="季" outlined dense />
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VSelect v-model="data.sites" label="站点" :items="siteOptions" chips multiple outlined dense />
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="data.search_type === 'keyword'">
|
||||
<VCol cols="12">
|
||||
<VSwitch v-model="data.match_media" label="匹配媒体信息" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<Handle id="edge_out" type="source" :position="Position.Right" />
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
81
src/components/workflow/FilterMediasAction.vue
Normal file
81
src/components/workflow/FilterMediasAction.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 电影/电视剧下拉框
|
||||
const typeOptions = ref([
|
||||
{
|
||||
title: '电影',
|
||||
value: '电影',
|
||||
},
|
||||
{
|
||||
title: '电视剧',
|
||||
value: '电视剧',
|
||||
},
|
||||
])
|
||||
|
||||
// 二级分类策略
|
||||
const mediaCategories = ref<{ [key: string]: any }>({})
|
||||
|
||||
// 调用API查询自动分类配置
|
||||
async function loadMediaCategories() {
|
||||
try {
|
||||
mediaCategories.value = await api.get('media/category')
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 根据选中的媒体类型,获取对应的媒体类别
|
||||
const getCategories = computed(() => {
|
||||
const default_value = [{ title: '全部', value: '' }]
|
||||
if (!mediaCategories.value || !mediaCategories.value[props.data.type ?? '']) return default_value
|
||||
return default_value.concat(mediaCategories.value[props.data.type ?? ''])
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadMediaCategories()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard max-width="20rem">
|
||||
<Handle id="edge_in" type="target" :position="Position.Left" />
|
||||
<VCardItem>
|
||||
<template v-slot:prepend>
|
||||
<VAvatar>
|
||||
<VIcon icon="mdi-filter-check" size="x-large"></VIcon>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>过滤媒体数据</VCardTitle>
|
||||
<VCardSubtitle>对媒体数据列表进行过滤</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VSelect v-model="data.type" label="类型" :items="typeOptions" outlined dense />
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField v-model="data.year" label="年份" outlined dense />
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField v-model="data.vote" type="number" label="评分" outlined dense />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<Handle id="edge_out" type="source" :position="Position.Right" />
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
176
src/components/workflow/FilterTorrentsAction.vue
Normal file
176
src/components/workflow/FilterTorrentsAction.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { FilterRuleGroup } from '@/api/types'
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
|
||||
defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 质量选择框数据
|
||||
const qualityOptions = ref([
|
||||
{
|
||||
title: '全部',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
title: '蓝光原盘',
|
||||
value: 'Blu-?Ray.+VC-?1|Blu-?Ray.+AVC|UHD.+blu-?ray.+HEVC|MiniBD',
|
||||
},
|
||||
{
|
||||
title: 'Remux',
|
||||
value: 'Remux',
|
||||
},
|
||||
{
|
||||
title: 'BluRay',
|
||||
value: 'Blu-?Ray',
|
||||
},
|
||||
{
|
||||
title: 'UHD',
|
||||
value: 'UHD|UltraHD',
|
||||
},
|
||||
{
|
||||
title: 'WEB-DL',
|
||||
value: 'WEB-?DL|WEB-?RIP',
|
||||
},
|
||||
{
|
||||
title: 'HDTV',
|
||||
value: 'HDTV',
|
||||
},
|
||||
{
|
||||
title: 'H265',
|
||||
value: '[Hx].?265|HEVC',
|
||||
},
|
||||
{
|
||||
title: 'H264',
|
||||
value: '[Hx].?264|AVC',
|
||||
},
|
||||
])
|
||||
|
||||
// 分辨率选择框数据
|
||||
const resolutionOptions = ref([
|
||||
{
|
||||
title: '全部',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
title: '4k',
|
||||
value: '4K|2160p|x2160',
|
||||
},
|
||||
{
|
||||
title: '1080p',
|
||||
value: '1080[pi]|x1080',
|
||||
},
|
||||
{
|
||||
title: '720p',
|
||||
value: '720[pi]|x720',
|
||||
},
|
||||
])
|
||||
|
||||
// 特效选择框数据
|
||||
const effectOptions = ref([
|
||||
{
|
||||
title: '全部',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
title: '杜比视界',
|
||||
value: 'Dolby[\\s.]+Vision|DOVI|[\\s.]+DV[\\s.]+',
|
||||
},
|
||||
{
|
||||
title: '杜比全景声',
|
||||
value: 'Dolby[\\s.]*\\+?Atmos|Atmos',
|
||||
},
|
||||
{
|
||||
title: 'HDR',
|
||||
value: '[\\s.]+HDR[\\s.]+|HDR10|HDR10\\+',
|
||||
},
|
||||
{
|
||||
title: 'SDR',
|
||||
value: '[\\s.]+SDR[\\s.]+',
|
||||
},
|
||||
])
|
||||
|
||||
// 所有规则组列表
|
||||
const filterRuleGroups = ref<FilterRuleGroup[]>([])
|
||||
|
||||
// 加载规则组
|
||||
async function queryFilterRuleGroups() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
|
||||
filterRuleGroups.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算过滤规则组选择框数据
|
||||
const ruleGroupsOptions = computed(() => {
|
||||
return filterRuleGroups.value.map(group => ({
|
||||
title: group.name,
|
||||
value: group.name,
|
||||
}))
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
queryFilterRuleGroups()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard max-width="20rem">
|
||||
<Handle id="edge_in" type="target" :position="Position.Left" />
|
||||
<VCardItem>
|
||||
<template v-slot:prepend>
|
||||
<VAvatar>
|
||||
<VIcon icon="mdi-filter-multiple" size="x-large"></VIcon>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>过滤资源</VCardTitle>
|
||||
<VCardSubtitle>对资源列表数据进行过滤</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="6">
|
||||
<VSelect v-model="data.quality" label="质量" :items="qualityOptions" outlined dense />
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VSelect v-model="data.resolution" label="分辨率" :items="resolutionOptions" outlined dense />
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VSelect v-model="data.effect" label="特效" :items="effectOptions" outlined dense />
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField v-model="data.size" label="大小范围" placeholder="MB" outlined dense />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="data.include" label="包含(关键字、正则式)" outlined dense />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="data.exclude" label="排除(关键字、正则式)" outlined dense />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VSelect
|
||||
v-model="data.rule_groups"
|
||||
chips
|
||||
multiple
|
||||
label="过滤规则组"
|
||||
:items="ruleGroupsOptions"
|
||||
outlined
|
||||
dense
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<Handle id="edge_out" type="source" :position="Position.Right" />
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
43
src/components/workflow/ScanFileAction.vue
Normal file
43
src/components/workflow/ScanFileAction.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import { storageOptions } from '@/api/constants'
|
||||
|
||||
defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard max-width="20rem">
|
||||
<Handle id="edge_in" type="target" :position="Position.Left" />
|
||||
<VCardItem>
|
||||
<template v-slot:prepend>
|
||||
<VAvatar>
|
||||
<VIcon icon="mdi-file-move" size="x-large"></VIcon>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>扫描目录</VCardTitle>
|
||||
<VCardSubtitle>扫描目录文件到队列</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VSelect v-model="data.storage" label="存储" :items="storageOptions" outlined dense />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VPathField v-model="data.directory" :storage="data.storage" label="目录" clearable />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<Handle id="edge_out" type="source" :position="Position.Right" />
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
31
src/components/workflow/ScrapeFileAction.vue
Normal file
31
src/components/workflow/ScrapeFileAction.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
|
||||
defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard max-width="20rem">
|
||||
<Handle id="edge_in" type="target" :position="Position.Left" />
|
||||
<VCardItem>
|
||||
<template v-slot:prepend>
|
||||
<VAvatar>
|
||||
<VIcon icon="mdi-file-find" size="x-large"></VIcon>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>刮削文件</VCardTitle>
|
||||
<VCardSubtitle>刮削媒体信息和图片</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<Handle id="edge_out" type="source" :position="Position.Right" />
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
31
src/components/workflow/SendEventAction.vue
Normal file
31
src/components/workflow/SendEventAction.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
|
||||
defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard max-width="20rem">
|
||||
<Handle id="edge_in" type="target" :position="Position.Left" />
|
||||
<VCardItem>
|
||||
<template v-slot:prepend>
|
||||
<VAvatar>
|
||||
<VIcon icon="mdi-send-check" size="x-large"></VIcon>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>发送事件</VCardTitle>
|
||||
<VCardSubtitle>发送任务执行事件</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<Handle id="edge_out" type="source" :position="Position.Right" />
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
80
src/components/workflow/SendMessageAction.vue
Normal file
80
src/components/workflow/SendMessageAction.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { NotificationConf } from '@/api/types'
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
|
||||
defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 所有消息渠道
|
||||
const notifications = ref<NotificationConf[]>([])
|
||||
|
||||
// 调用API查询通知渠道设置
|
||||
async function loadNotificationSetting() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Notifications')
|
||||
notifications.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算消息渠道选项
|
||||
const sourceOptions = computed(() => {
|
||||
return notifications.value.map(item => {
|
||||
return {
|
||||
title: item.name,
|
||||
value: item.name,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadNotificationSetting()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard max-width="20rem">
|
||||
<Handle id="edge_in" type="target" :position="Position.Left" />
|
||||
<VCardItem>
|
||||
<template v-slot:prepend>
|
||||
<VAvatar>
|
||||
<VIcon icon="mdi-message-arrow-right" size="x-large"></VIcon>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>发送消息</VCardTitle>
|
||||
<VCardSubtitle>发送任务执行消息</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VSelect
|
||||
v-model="data.client"
|
||||
:items="sourceOptions"
|
||||
label="消息渠道"
|
||||
chips
|
||||
multiple
|
||||
outlined
|
||||
dense
|
||||
clearable
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="data.userid" label="用户ID" chips multiple outlined dense clearable />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<Handle id="edge_out" type="source" :position="Position.Right" />
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
51
src/components/workflow/TransferFileAction.vue
Normal file
51
src/components/workflow/TransferFileAction.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
|
||||
defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 来源下拉框
|
||||
const sourceOptions = ref([
|
||||
{
|
||||
title: '文件列表',
|
||||
value: 'files',
|
||||
},
|
||||
{
|
||||
title: '下载任务',
|
||||
value: 'downloads',
|
||||
},
|
||||
])
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard max-width="20rem">
|
||||
<Handle id="edge_in" type="target" :position="Position.Left" />
|
||||
<VCardItem>
|
||||
<template v-slot:prepend>
|
||||
<VAvatar>
|
||||
<VIcon icon="mdi-file-move" size="x-large"></VIcon>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>整理文件</VCardTitle>
|
||||
<VCardSubtitle>整理重命名队列中的文件</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VSelect v-model="data.source" label="来源" :items="sourceOptions" outlined dense />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<Handle id="edge_out" type="source" :position="Position.Right" />
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -8,7 +8,7 @@ import UserNofification from '@/layouts/components/UserNotification.vue'
|
||||
import SearchBar from '@/layouts/components/SearchBar.vue'
|
||||
import ShortcutBar from '@/layouts/components/ShortcutBar.vue'
|
||||
import UserProfile from '@/layouts/components/UserProfile.vue'
|
||||
import store from '@/store'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { SystemNavMenus } from '@/router/menu'
|
||||
import { NavMenu } from '@/@layouts/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
@@ -16,8 +16,11 @@ import { useDisplay } from 'vuetify'
|
||||
const display = useDisplay()
|
||||
const appMode = inject('pwaMode')
|
||||
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 是否超级用户
|
||||
let superUser = store.state.auth.superUser
|
||||
let superUser = userStore.superUser
|
||||
|
||||
// 开始菜单项
|
||||
const startMenus = ref<NavMenu[]>([])
|
||||
@@ -64,7 +67,7 @@ onMounted(() => {
|
||||
<VIcon icon="mdi-menu" />
|
||||
</IconBtn>
|
||||
<!-- 👉 Back Button -->
|
||||
<IconBtn v-if="appMode && display.mdAndDown.value" class="ms-n2" @click="goBack">
|
||||
<IconBtn v-if="appMode" class="ms-n2" @click="goBack">
|
||||
<VIcon icon="mdi-arrow-left" size="32" />
|
||||
</IconBtn>
|
||||
<!-- 👉 Search Bar -->
|
||||
|
||||
12
src/layouts/components/DropzoneBackground.vue
Normal file
12
src/layouts/components/DropzoneBackground.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
import { Background } from '@vue-flow/background'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dropzone-background">
|
||||
<Background :size="2" :gap="20" pattern-color="#BDBDBD" />
|
||||
<div class="overlay">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,38 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { SystemNavMenus } from '@/router/menu'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { VMenu } from 'vuetify/lib/components/index.mjs'
|
||||
|
||||
const display = useDisplay()
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// 各按钮活动状态
|
||||
const moreMenuDialog = ref(false)
|
||||
|
||||
const moreMemus = computed(() => SystemNavMenus.filter(menu => !menu.footer))
|
||||
|
||||
const activeState = computed(() => {
|
||||
return {
|
||||
home: route.path === '/dashboard',
|
||||
ranking: route.path === '/ranking',
|
||||
recommend: route.path === '/recommend',
|
||||
movie: route.path === '/subscribe/movie',
|
||||
tv: route.path === '/subscribe/tv',
|
||||
apps: route.path === '/apps',
|
||||
}
|
||||
})
|
||||
|
||||
const moreActiveState = computed(() => {
|
||||
return !Object.values(activeState.value).some(v => v)
|
||||
})
|
||||
|
||||
const currentPath = computed(() => route.path)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="appMode" class="w-100" style="block-size: calc(3.5rem + env(safe-area-inset-bottom))">
|
||||
<div v-if="appMode" class="w-100">
|
||||
<VBottomNavigation
|
||||
grow
|
||||
horizontal
|
||||
color="primary"
|
||||
class="footer-nav border-t"
|
||||
style="block-size: calc(3.5rem + env(safe-area-inset-bottom))"
|
||||
:z-index="9998"
|
||||
>
|
||||
<VBtn to="/dashboard" :ripple="false">
|
||||
<VIcon v-if="activeState.home" size="28">mdi-home</VIcon>
|
||||
<VIcon v-else size="28">mdi-home-outline</VIcon>
|
||||
</VBtn>
|
||||
<VBtn to="/ranking" :ripple="false">
|
||||
<VIcon v-if="activeState.ranking" size="28">mdi-star</VIcon>
|
||||
<VBtn to="/recommend" :ripple="false">
|
||||
<VIcon v-if="activeState.recommend" size="28">mdi-star</VIcon>
|
||||
<VIcon v-else size="28">mdi-star-outline</VIcon>
|
||||
</VBtn>
|
||||
<VBtn to="/subscribe/movie" :ripple="false">
|
||||
@@ -43,9 +54,30 @@ const activeState = computed(() => {
|
||||
<VIcon v-if="activeState.tv" size="28">mdi-television-play</VIcon>
|
||||
<VIcon v-else size="28">mdi-television</VIcon>
|
||||
</VBtn>
|
||||
<VBtn to="/apps" :ripple="false">
|
||||
<VIcon v-if="activeState.apps" size="28">mdi-dots-horizontal-circle</VIcon>
|
||||
<VIcon v-else size="28">mdi-dots-horizontal</VIcon>
|
||||
<VBtn :ripple="false">
|
||||
<VIcon
|
||||
size="28"
|
||||
:icon="moreMenuDialog ? 'mdi-close' : 'mdi-dots-horizontal'"
|
||||
:color="moreActiveState ? 'primary' : ''"
|
||||
/>
|
||||
<VMenu v-model="moreMenuDialog" close-on-content-click activator="parent">
|
||||
<VList class="font-bold" lines="one" elevation="1">
|
||||
<VListSubheader class="bg-transparent"> 更多 </VListSubheader>
|
||||
<VListItem
|
||||
class="pe-20"
|
||||
v-for="(menu, index) in moreMemus"
|
||||
:key="index"
|
||||
:prepend-icon="menu.icon"
|
||||
nav
|
||||
:to="menu.to"
|
||||
:base-color="currentPath === menu.to ? 'primary' : undefined"
|
||||
>
|
||||
<VListItemTitle>
|
||||
<span class="text-lg">{{ menu.title }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</VBtn>
|
||||
</VBottomNavigation>
|
||||
</div>
|
||||
@@ -64,4 +96,3 @@ const activeState = computed(() => {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import * as Mousetrap from 'mousetrap'
|
||||
import SearchBarView from '@/views/system/SearchBarView.vue'
|
||||
import SearchBarDialog from '@/components/dialog/SearchBarDialog.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
const display = useDisplay()
|
||||
@@ -36,7 +36,7 @@ const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
|
||||
</span>
|
||||
</div>
|
||||
<!-- 搜索弹窗 -->
|
||||
<SearchBarView v-model="searchDialog" v-if="searchDialog" @close="searchDialog = false" />
|
||||
<SearchBarDialog v-model="searchDialog" v-if="searchDialog" @close="searchDialog = false" />
|
||||
</template>
|
||||
<style type="scss" scoped>
|
||||
.meta-key {
|
||||
|
||||
@@ -15,6 +15,9 @@ const display = useDisplay()
|
||||
// App捷径
|
||||
const appsMenu = ref(false)
|
||||
|
||||
// 菜单最大宽度
|
||||
const menuMaxWidth = ref(480)
|
||||
|
||||
// 名称测试弹窗
|
||||
const nameTestDialog = ref(false)
|
||||
|
||||
@@ -40,14 +43,13 @@ const user_message = ref('')
|
||||
const sendButtonDisabled = ref(false)
|
||||
|
||||
// 聊天容器
|
||||
const chatContainer = ref<HTMLDivElement>()
|
||||
const chatContainer = ref<HTMLElement>()
|
||||
|
||||
// 滚动到底部
|
||||
function scrollMessageToEnd() {
|
||||
nextTick(() => {
|
||||
if (chatContainer.value) {
|
||||
const scrollDiv = chatContainer.value.$el
|
||||
scrollDiv.scrollTop = scrollDiv.scrollHeight
|
||||
chatContainer.value.scrollTop = chatContainer.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -103,91 +105,99 @@ onMounted(() => {
|
||||
<template>
|
||||
<VMenu
|
||||
v-model="appsMenu"
|
||||
max-width="600"
|
||||
width="340"
|
||||
:max-width="menuMaxWidth"
|
||||
width="100%"
|
||||
max-height="560"
|
||||
location="top end"
|
||||
origin="top end"
|
||||
transition="scale-transition"
|
||||
close-on-content-click
|
||||
close-on-back
|
||||
>
|
||||
<!-- Menu Activator -->
|
||||
<template #activator="{ props }">
|
||||
<IconBtn class="ms-2" v-bind="props">
|
||||
<VIcon icon="mdi-checkbox-multiple-blank-outline" />
|
||||
<VIcon icon="mdi-apps" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
<!-- Menu Content -->
|
||||
<VCard>
|
||||
<VCardItem class="border-b">
|
||||
<VCardTitle>捷径</VCardTitle>
|
||||
<VCard elevation="1" class="shortcut-menu-card">
|
||||
<VCardItem class="shortcut-header border-b">
|
||||
<VCardTitle class="font-weight-medium text-primary">捷径</VCardTitle>
|
||||
<template #append>
|
||||
<IconBtn @click="() => {}">
|
||||
<VIcon icon="mdi-checkbox-multiple-blank-outline" />
|
||||
<IconBtn @click="appsMenu = false" class="shortcut-close-btn">
|
||||
<VIcon icon="mdi-close" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</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">
|
||||
<VIcon icon="mdi-text-recognition" />
|
||||
</VAvatar>
|
||||
<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">
|
||||
<VIcon icon="mdi-filter-cog-outline" />
|
||||
</VAvatar>
|
||||
<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">
|
||||
<VIcon icon="mdi-file-document-outline" />
|
||||
</VAvatar>
|
||||
<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">
|
||||
<VIcon icon="mdi-network-outline" />
|
||||
</VAvatar>
|
||||
<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">
|
||||
<VIcon icon="mdi-cog-outline" />
|
||||
</VAvatar>
|
||||
<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>
|
||||
<div class="ps ps--active-y shortcut-menu-container">
|
||||
<div class="shortcut-grid">
|
||||
<!-- 识别 -->
|
||||
<div class="shortcut-item" @click="nameTestDialog = true">
|
||||
<div class="shortcut-icon-wrapper">
|
||||
<VIcon icon="mdi-text-recognition" size="24" />
|
||||
</div>
|
||||
<div class="shortcut-text">
|
||||
<div class="shortcut-title">识别</div>
|
||||
<div class="shortcut-subtitle">名称识别测试</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 规则 -->
|
||||
<div class="shortcut-item" @click="ruleTestDialog = true">
|
||||
<div class="shortcut-icon-wrapper">
|
||||
<VIcon icon="mdi-filter-cog" size="24" />
|
||||
</div>
|
||||
<div class="shortcut-text">
|
||||
<div class="shortcut-title">规则</div>
|
||||
<div class="shortcut-subtitle">规则测试</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志 -->
|
||||
<div class="shortcut-item" @click="loggingDialog = true">
|
||||
<div class="shortcut-icon-wrapper">
|
||||
<VIcon icon="mdi-file-document" size="24" />
|
||||
</div>
|
||||
<div class="shortcut-text">
|
||||
<div class="shortcut-title">日志</div>
|
||||
<div class="shortcut-subtitle">实时日志</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 网络 -->
|
||||
<div class="shortcut-item" @click="netTestDialog = true">
|
||||
<div class="shortcut-icon-wrapper">
|
||||
<VIcon icon="mdi-network" size="24" />
|
||||
</div>
|
||||
<div class="shortcut-text">
|
||||
<div class="shortcut-title">网络</div>
|
||||
<div class="shortcut-subtitle">网速连通性测试</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统 -->
|
||||
<div class="shortcut-item" @click="systemTestDialog = true">
|
||||
<div class="shortcut-icon-wrapper">
|
||||
<VIcon icon="mdi-cog" size="24" />
|
||||
</div>
|
||||
<div class="shortcut-text">
|
||||
<div class="shortcut-title">系统</div>
|
||||
<div class="shortcut-subtitle">健康检查</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息 -->
|
||||
<div class="shortcut-item" @click="messageDialog = true">
|
||||
<div class="shortcut-icon-wrapper">
|
||||
<VIcon icon="mdi-message" size="24" />
|
||||
</div>
|
||||
<div class="shortcut-text">
|
||||
<div class="shortcut-title">消息</div>
|
||||
<div class="shortcut-subtitle">消息中心</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</VMenu>
|
||||
@@ -262,7 +272,7 @@ onMounted(() => {
|
||||
<VDialog
|
||||
v-if="messageDialog"
|
||||
v-model="messageDialog"
|
||||
max-width="60rem"
|
||||
max-width="45rem"
|
||||
scrollable
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
@@ -289,3 +299,114 @@ onMounted(() => {
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.shortcut-menu-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shortcut-header {
|
||||
background: linear-gradient(to right, rgba(var(--v-theme-primary), 0.04), rgba(var(--v-theme-primary), 0.01));
|
||||
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
padding-block: 12px;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.shortcut-close-btn {
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.shortcut-menu-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.shortcut-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.shortcut-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
block-size: 48px;
|
||||
inline-size: 48px;
|
||||
margin-inline-end: 16px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
.v-icon {
|
||||
color: rgba(var(--v-theme-primary), 1);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.shortcut-item {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.05);
|
||||
border-radius: 12px;
|
||||
background-color: rgba(var(--v-theme-surface));
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.08) 0%, rgba(var(--v-theme-primary), 0) 60%);
|
||||
content: '';
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.15);
|
||||
transform: translateY(-4px);
|
||||
|
||||
&::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.shortcut-icon-wrapper {
|
||||
background-color: rgba(var(--v-theme-primary), 0.12);
|
||||
transform: scale(1.1);
|
||||
|
||||
.v-icon {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 3px 10px rgba(var(--v-theme-on-surface), 0.08);
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.shortcut-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.shortcut-title {
|
||||
color: rgba(var(--v-theme-on-surface), 0.95);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-block-end: 4px;
|
||||
}
|
||||
|
||||
.shortcut-subtitle {
|
||||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -24,7 +24,6 @@ function startSSEMessager() {
|
||||
const noti: SystemNotification = JSON.parse(event.data)
|
||||
notificationList.value.unshift(noti)
|
||||
hasNewMessage.value = true
|
||||
// TODO 在顶部显示消息汽泡
|
||||
}
|
||||
})
|
||||
}, 3000)
|
||||
@@ -40,8 +39,9 @@ onBeforeUnmount(() => {
|
||||
if (eventSource) eventSource.close()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VMenu v-model="appsMenu" width="400" transition="scale-transition" close-on-content-click>
|
||||
<VMenu v-model="appsMenu" width="400" transition="scale-transition" close-on-content-click class="notification-menu">
|
||||
<!-- Menu Activator -->
|
||||
<template #activator="{ props }">
|
||||
<VBadge v-if="hasNewMessage" dot color="error" :offset-x="5" :offset-y="5" v-bind="props">
|
||||
@@ -54,13 +54,14 @@ onBeforeUnmount(() => {
|
||||
</IconBtn>
|
||||
</template>
|
||||
<!-- Menu Content -->
|
||||
<VCard>
|
||||
<VCardItem class="border-b">
|
||||
<VCardTitle>通知</VCardTitle>
|
||||
<VCard elevation="0">
|
||||
<VCardItem class="notification-header">
|
||||
<VCardTitle class="font-weight-medium text-primary">通知中心</VCardTitle>
|
||||
<template #append>
|
||||
<VTooltip text="设为已读">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn
|
||||
class="mark-read-btn"
|
||||
v-bind="props"
|
||||
@click="
|
||||
() => {
|
||||
@@ -69,33 +70,107 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
"
|
||||
>
|
||||
<VIcon icon="mdi-email-mark-as-unread" />
|
||||
<VIcon icon="mdi-email-check-outline" size="20" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VList lines="two" v-if="notificationList.length > 0" max-height="600">
|
||||
<VListItem v-for="(item, i) in notificationList" :key="i">
|
||||
<div v-if="notificationList.length > 0" class="notification-list">
|
||||
<VListItem v-for="(item, i) in notificationList" :key="i" lines="two" class="notification-item">
|
||||
<template #prepend>
|
||||
<VAvatar rounded>
|
||||
<VAvatar rounded class="notification-avatar">
|
||||
<VIcon v-if="item.type === 'user'" icon="mdi-account-alert" size="large"></VIcon>
|
||||
<VIcon v-else-if="item.type === 'plugin'" icon="mdi-robot-happy" size="large"></VIcon>
|
||||
<VIcon v-else-if="item.type === 'plugin'" icon="mdi-robot" size="large"></VIcon>
|
||||
<VIcon v-else icon="mdi-laptop" size="large"></VIcon>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VListItemTitle class="overflow-visiable break-words whitespace-break-spaces">
|
||||
{{ item.title }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="mt-2">{{ item.text }}</VListItemSubtitle>
|
||||
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
|
||||
<div class="notification-content">
|
||||
<div class="notification-title overflow-visiable break-words whitespace-break-spaces">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div class="notification-text">{{ item.text }}</div>
|
||||
<div class="notification-time">{{ formatDateDifference(item.date) }}</div>
|
||||
</div>
|
||||
</VListItem>
|
||||
</VList>
|
||||
<VList v-else>
|
||||
<VListItem>
|
||||
<VListItemTitle class="text-center">暂无通知</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</div>
|
||||
<div v-else class="no-notification">
|
||||
<div class="text-center">
|
||||
<VIcon icon="mdi-bell-sleep-outline" size="40" class="mb-3 text-primary" />
|
||||
<div>暂无通知</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</VMenu>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.notification-header {
|
||||
background: linear-gradient(to right, rgba(var(--v-theme-primary), 0.04), rgba(var(--v-theme-primary), 0.01));
|
||||
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
padding-block: 12px;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
padding: 8px;
|
||||
max-block-size: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.05);
|
||||
border-radius: 12px;
|
||||
margin-block-end: 8px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.03);
|
||||
box-shadow: 0 4px 8px rgba(var(--v-theme-on-surface), 0.06);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.notification-avatar {
|
||||
background-color: rgba(var(--v-theme-primary), 0.1);
|
||||
box-shadow: 0 4px 8px rgba(var(--v-theme-primary), 0.15);
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notification-text {
|
||||
color: rgba(var(--v-theme-on-surface), 0.75);
|
||||
font-size: 0.85rem;
|
||||
margin-block-start: 6px;
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
color: rgba(var(--v-theme-primary), 0.8);
|
||||
font-size: 0.8rem;
|
||||
margin-block-start: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.no-notification {
|
||||
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||
font-size: 0.95rem;
|
||||
padding-block: 30px;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.mark-read-btn {
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.1);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.notification-menu .v-overlay__content {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { useStore } from 'vuex'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import router from '@/router'
|
||||
@@ -7,9 +6,12 @@ import avatar1 from '@images/avatars/avatar-1.png'
|
||||
import api from '@/api'
|
||||
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||
import UserAuthDialog from '@/components/dialog/UserAuthDialog.vue'
|
||||
import { useAuthStore, useUserStore } from '@/stores'
|
||||
|
||||
// Vuex Store
|
||||
const store = useStore()
|
||||
// 认证 Store
|
||||
const authStore = useAuthStore()
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
@@ -29,33 +31,31 @@ const restartDialog = ref(false)
|
||||
// 执行注销操作
|
||||
function logout() {
|
||||
// 清除登录状态信息
|
||||
store.dispatch('auth/logout')
|
||||
authStore.logout()
|
||||
// 重定向到登录页面或其他适当的页面
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
// 执行重启操作
|
||||
async function restart() {
|
||||
{
|
||||
restartDialog.value = false
|
||||
// 调用API重启
|
||||
try {
|
||||
// 显示等待框
|
||||
progressDialog.value = true
|
||||
const result: { [key: string]: any } = await api.get('system/restart')
|
||||
if (!result?.success) {
|
||||
// 隐藏等待框
|
||||
progressDialog.value = false
|
||||
// 重启不成功
|
||||
$toast.error(result.message)
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
restartDialog.value = false
|
||||
// 调用API重启
|
||||
try {
|
||||
// 显示等待框
|
||||
progressDialog.value = true
|
||||
const result: { [key: string]: any } = await api.get('system/restart')
|
||||
if (!result?.success) {
|
||||
// 隐藏等待框
|
||||
progressDialog.value = false
|
||||
// 重启不成功
|
||||
$toast.error(result.message)
|
||||
return
|
||||
}
|
||||
// 注销
|
||||
logout()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
// 注销
|
||||
logout()
|
||||
}
|
||||
|
||||
// 显示重启确认对话框
|
||||
@@ -74,79 +74,95 @@ function siteAuthDone() {
|
||||
logout()
|
||||
}
|
||||
|
||||
// 从Vuex Store中获取信息
|
||||
const superUser = computed(() => store.state.auth.superUser)
|
||||
const userName = computed(() => store.state.auth.userName)
|
||||
const avatar = computed(() => store.state.auth.avatar || avatar1)
|
||||
const userLevel = computed(() => store.state.auth.level)
|
||||
// 从用户 Store中获取信息
|
||||
const superUser = computed(() => userStore.superUser)
|
||||
const userName = computed(() => userStore.userName)
|
||||
const avatar = computed(() => userStore.avatar || avatar1)
|
||||
const userLevel = computed(() => userStore.level)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VAvatar class="cursor-pointer ms-3" color="primary" variant="tonal">
|
||||
<VImg :src="avatar" />
|
||||
|
||||
<VMenu activator="parent" width="230" location="bottom end" offset="14px">
|
||||
<VList>
|
||||
<VMenu activator="parent" width="230" location="bottom end" offset="14px" class="user-menu">
|
||||
<VList elevation="0" class="overflow-hidden">
|
||||
<!-- 👉 User Avatar & Name -->
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<VListItemAction start>
|
||||
<VAvatar color="primary" variant="tonal">
|
||||
<VImg :src="avatar" />
|
||||
</VAvatar>
|
||||
</VListItemAction>
|
||||
</template>
|
||||
<div class="user-profile-header px-2 py-4 mb-2">
|
||||
<div class="d-flex align-center">
|
||||
<VAvatar size="60" class="user-avatar" color="primary" rounded="sm">
|
||||
<VImg :src="avatar" />
|
||||
</VAvatar>
|
||||
<div class="ms-4">
|
||||
<div class="user-role">
|
||||
{{ superUser ? '管理员' : '普通用户' }}
|
||||
</div>
|
||||
<div class="user-name">
|
||||
{{ userName }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2">
|
||||
<!-- 👉 Profile -->
|
||||
<VListItem link @click="router.push('/profile')" class="user-menu-item mb-1">
|
||||
<template #prepend>
|
||||
<div class="user-menu-icon">
|
||||
<VIcon icon="mdi-account-outline" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle>个人信息</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItemTitle class="font-weight-semibold">
|
||||
{{ superUser ? '管理员' : '普通用户' }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle>{{ userName }}</VListItemSubtitle>
|
||||
</VListItem>
|
||||
<VListItem link @click="router.push('/apps')" class="user-menu-item mb-1">
|
||||
<template #prepend>
|
||||
<div class="user-menu-icon">
|
||||
<VIcon icon="mdi-view-grid-outline" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle>功能视图</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<VDivider class="my-2" />
|
||||
<!-- 👉 Site Auth -->
|
||||
<VListItem v-if="userLevel < 2 && superUser" link @click="showSiteAuthDialog" class="user-menu-item mb-1">
|
||||
<template #prepend>
|
||||
<div class="user-menu-icon">
|
||||
<VIcon icon="mdi-lock-check-outline" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle>用户认证</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<!-- 👉 Profile -->
|
||||
<VListItem link @click="router.push('/profile')">
|
||||
<template #prepend>
|
||||
<VIcon class="me-2" icon="mdi-account-outline" size="22" />
|
||||
</template>
|
||||
<VListItemTitle>个人信息</VListItemTitle>
|
||||
</VListItem>
|
||||
<!-- 👉 FAQ -->
|
||||
<VListItem href="https://wiki.movie-pilot.org" target="_blank" class="user-menu-item mb-1">
|
||||
<template #prepend>
|
||||
<div class="user-menu-icon">
|
||||
<VIcon icon="mdi-help-circle-outline" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle>帮助文档</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<!-- 👉 Site Auth -->
|
||||
<VListItem v-if="userLevel < 2 && superUser" link @click="showSiteAuthDialog">
|
||||
<template #prepend>
|
||||
<VIcon class="me-2" icon="mdi-lock-check-outline" size="22" />
|
||||
</template>
|
||||
<VListItemTitle>用户认证</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<!-- 👉 FAQ -->
|
||||
<VListItem href="https://wiki.movie-pilot.org" target="_blank">
|
||||
<template #prepend>
|
||||
<VIcon class="me-2" icon="mdi-help-circle-outline" size="22" />
|
||||
</template>
|
||||
<VListItemTitle>帮助文档</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<!-- Divider -->
|
||||
<VDivider v-if="superUser" class="my-2" />
|
||||
|
||||
<!-- 👉 restart -->
|
||||
<VListItem v-if="superUser" @click="showRestartDialog">
|
||||
<template #prepend>
|
||||
<VIcon class="me-2" icon="mdi-restart" size="22" />
|
||||
</template>
|
||||
<VListItemTitle>重启</VListItemTitle>
|
||||
</VListItem>
|
||||
<!-- Divider -->
|
||||
<VDivider v-if="superUser" class="my-3" />
|
||||
|
||||
<!-- 👉 restart -->
|
||||
<VListItem v-if="superUser" @click="showRestartDialog" class="user-menu-item mb-1">
|
||||
<template #prepend>
|
||||
<div class="user-menu-icon restart-icon">
|
||||
<VIcon icon="mdi-restart" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle>重启</VListItemTitle>
|
||||
</VListItem>
|
||||
</div>
|
||||
<!-- 👉 Logout -->
|
||||
<VListItem @click="logout">
|
||||
<VBtn color="error" block>
|
||||
<template #append> <VIcon size="small" icon="mdi-logout" /> </template>
|
||||
<div class="px-2 mt-3 mb-2">
|
||||
<VBtn color="error" block class="logout-btn" @click="logout">
|
||||
<template #prepend> <VIcon icon="mdi-logout" /> </template>
|
||||
退出登录
|
||||
</VBtn>
|
||||
</VListItem>
|
||||
</div>
|
||||
</VList>
|
||||
</VMenu>
|
||||
<!-- !SECTION -->
|
||||
@@ -156,7 +172,7 @@ const userLevel = computed(() => store.state.auth.level)
|
||||
<!-- 用户认证对话框 -->
|
||||
<UserAuthDialog v-if="siteAuthDialog" v-model="siteAuthDialog" @done="siteAuthDone" @close="siteAuthDialog = false" />
|
||||
<!-- 重启确认对话框 -->
|
||||
<VDialog v-model="restartDialog" max-width="25rem">
|
||||
<VDialog v-if="restartDialog" v-model="restartDialog" max-width="25rem">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<div class="flex items-center justify-center mt-3">
|
||||
@@ -177,3 +193,76 @@ const userLevel = computed(() => store.state.auth.level)
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-profile-header {
|
||||
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.05), rgba(var(--v-theme-primary), 0.02));
|
||||
}
|
||||
|
||||
.user-role {
|
||||
color: rgba(var(--v-theme-primary), 0.9);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-block-end: 4px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
color: rgba(var(--v-theme-on-surface), 0.9);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
border: 2px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
box-shadow: 0 4px 12px rgba(var(--v-theme-primary), 0.2);
|
||||
}
|
||||
|
||||
.user-menu-item {
|
||||
border-radius: 8px;
|
||||
margin-block: 4px;
|
||||
margin-inline: 0;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.06);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.user-menu-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
block-size: 36px;
|
||||
inline-size: 36px;
|
||||
margin-inline-end: 12px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.v-icon {
|
||||
color: rgba(var(--v-theme-primary), 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.restart-icon {
|
||||
background-color: rgba(var(--v-theme-error), 0.1);
|
||||
|
||||
.v-icon {
|
||||
color: rgba(var(--v-theme-error), 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 12px rgba(var(--v-theme-error), 0.2);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.user-menu .v-overlay__content {
|
||||
border-radius: 8px !important;
|
||||
box-shadow: 0 4px 12px rgba(var(--v-theme-on-surface), 0.08) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
40
src/layouts/components/WorkflowSidebar.vue
Normal file
40
src/layouts/components/WorkflowSidebar.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import useDragAndDrop from '@core/utils/workflow'
|
||||
|
||||
const { onDragStart } = useDragAndDrop()
|
||||
|
||||
// 组件列表
|
||||
const actions = ref([])
|
||||
|
||||
// 加载组件列表
|
||||
async function load_actions() {
|
||||
try {
|
||||
actions.value = await api.get('workflow/actions')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load_actions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside>
|
||||
<div class="mb-3"><VLabel>可选动作组件:</VLabel></div>
|
||||
|
||||
<div class="nodes flex flex-wrap justify-center">
|
||||
<div
|
||||
class="vue-flow__node-default cursor-grab mx-1"
|
||||
v-for="(action, index) in actions"
|
||||
:key="index"
|
||||
:draggable="true"
|
||||
@dragstart="onDragStart($event, action)"
|
||||
>
|
||||
{{ action['name'] }}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user