mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-11 01:50:10 +08:00
Compare commits
143 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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']>
|
||||
|
||||
363
index.html
363
index.html
@@ -1,160 +1,221 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en"
|
||||
style="overflow: hidden auto; min-block-size: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));background: var(--initial-loader-bg, #fff);">
|
||||
<html
|
||||
lang="en"
|
||||
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" />
|
||||
<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>
|
||||
|
||||
<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="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>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
14601
package-lock.json
generated
Normal file
14601
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.3.0",
|
||||
"version": "2.3.7-1",
|
||||
"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,7 +70,8 @@
|
||||
"@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/webfontloader": "^1.6.34",
|
||||
"@typescript-eslint/eslint-plugin": "^8.20.0",
|
||||
|
||||
@@ -209,7 +209,7 @@ onMounted(() => {
|
||||
</VList>
|
||||
</VMenu>
|
||||
<!-- 自定义 CSS -- -->
|
||||
<VDialog v-if="cssDialog" 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 />
|
||||
|
||||
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>
|
||||
|
||||
@@ -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,9 @@ 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-wrapper.layout-nav-type-vertical {
|
||||
// TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
|
||||
@@ -116,9 +121,7 @@ export default defineComponent({
|
||||
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 {
|
||||
@@ -203,4 +206,4 @@ export default defineComponent({
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</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" 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>
|
||||
|
||||
@@ -65,6 +65,10 @@ body,
|
||||
block-size: variables.$layout-vertical-nav-footer-height;
|
||||
}
|
||||
|
||||
.footer-content-container-noheight {
|
||||
block-size: 0px !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;
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -795,6 +795,8 @@ export interface User {
|
||||
permissions: { [key: string]: any }
|
||||
// 用户个性化设置 json
|
||||
settings: { [key: string]: string | null }
|
||||
// 昵称
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
// 存储空间
|
||||
@@ -1261,3 +1263,31 @@ export interface SiteCategory {
|
||||
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/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',
|
||||
@@ -154,10 +162,32 @@ function sortChanged(s: string) {
|
||||
sort.value = s
|
||||
refreshPending.value = true
|
||||
}
|
||||
|
||||
// 文件列表
|
||||
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 +201,33 @@ 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
|
||||
: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"
|
||||
@pathchanged="pathChanged"
|
||||
@loading="loadingChanged"
|
||||
@refreshed="refreshPending = false"
|
||||
@filedeleted="refreshPending = true"
|
||||
@renamed="refreshPending = true"
|
||||
@items-updated="fileListUpdated"
|
||||
/>
|
||||
</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'
|
||||
|
||||
// 输入参数
|
||||
|
||||
@@ -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,35 +143,42 @@ 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>
|
||||
</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" />
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -156,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"
|
||||
>
|
||||
|
||||
@@ -5,12 +5,13 @@ import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
|
||||
import { formatSeason, formatRating } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { MediaInfo, NotExistMediaInfo, Subscribe, MediaSeason } from '@/api/types'
|
||||
import type { MediaInfo, NotExistMediaInfo, Subscribe, MediaSeason, Site } 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 { useUserStore } from '@/stores'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -22,7 +23,8 @@ const props = defineProps({
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettings: any = inject('globalSettings')
|
||||
|
||||
const store = useStore()
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
@@ -73,6 +75,50 @@ 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 checkAllText = computed(() => (selectedSites.value.length === allSites.value.length ? '全不选' : '全选'))
|
||||
|
||||
// 全选/全不选
|
||||
function checkAllSitesorNot() {
|
||||
if (selectedSites.value.length === allSites.value.length) {
|
||||
selectedSites.value = []
|
||||
} else {
|
||||
selectedSites.value = allSites.value.map(item => item.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询所有站点
|
||||
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}`
|
||||
@@ -308,7 +354,7 @@ async function getMediaSeasons() {
|
||||
// 查询订阅弹窗规则
|
||||
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'
|
||||
@@ -375,6 +421,13 @@ function goMediaDetail(isHovering = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// 点击搜索
|
||||
async function clickSearch() {
|
||||
if (allSites.value?.length > 0) return
|
||||
querySites()
|
||||
querySelectedSites()
|
||||
}
|
||||
|
||||
// 开始搜索
|
||||
function handleSearch() {
|
||||
router.push({
|
||||
@@ -386,6 +439,7 @@ function handleSearch() {
|
||||
title: props.media?.title,
|
||||
year: props.media?.year,
|
||||
season: props.media?.season,
|
||||
sites: selectedSites.value.join(','),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -476,9 +530,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"
|
||||
: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)"
|
||||
@@ -499,7 +553,7 @@ function onRemoveSubscribe() {
|
||||
</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%)"
|
||||
>
|
||||
@@ -512,7 +566,36 @@ 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" />
|
||||
<VMenu close-on-content-click v-model="searchMenuShow" max-width="450">
|
||||
<template v-slot:activator="{ props }">
|
||||
<IconBtn v-bind="props" icon="mdi-magnify" color="white" @click.stop="clickSearch" />
|
||||
</template>
|
||||
<VList>
|
||||
<VListItem>
|
||||
<VChipGroup v-model="selectedSites" column multiple @click.stop>
|
||||
<VChip
|
||||
v-for="site in allSites"
|
||||
:key="site.id"
|
||||
:color="selectedSites.includes(site.id) ? 'primary' : ''"
|
||||
filter
|
||||
variant="outlined"
|
||||
:value="site.id"
|
||||
size="small"
|
||||
>
|
||||
{{ site.name }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
<div>
|
||||
<VBtn size="small" variant="text" @click.stop="checkAllSitesorNot">
|
||||
{{ checkAllText }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VListItem>
|
||||
<VListItem>
|
||||
<VBtn @click="handleSearch" block>搜索</VBtn>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
<IconBtn icon="mdi-heart" :color="isSubscribed ? 'error' : 'white'" @click.stop="handleSubscribe" />
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,54 +418,23 @@ watch(
|
||||
</VHover>
|
||||
|
||||
<!-- 插件配置页面 -->
|
||||
<VDialog
|
||||
<PluginConfigDialog
|
||||
v-if="pluginConfigDialog"
|
||||
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>
|
||||
:plugin="props.plugin"
|
||||
@save="configDone"
|
||||
@close="pluginConfigDialog = false"
|
||||
@switch="showPluginInfo"
|
||||
/>
|
||||
|
||||
<!-- 插件数据页面 -->
|
||||
<VDialog
|
||||
<PluginDataDialog
|
||||
v-if="pluginInfoDialog"
|
||||
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>
|
||||
:plugin="props.plugin"
|
||||
@close="pluginInfoDialog = false"
|
||||
@switch="showPluginConfig"
|
||||
/>
|
||||
|
||||
<!-- 进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||
|
||||
@@ -43,9 +43,9 @@ 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,
|
||||
}"
|
||||
>
|
||||
|
||||
@@ -117,10 +117,28 @@ const statColor = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// 计算上传量和下载量的百分比
|
||||
const getPercentage = computed(() => {
|
||||
if (cardProps.data?.upload === 0) return 100
|
||||
return ((cardProps.data?.download ?? 0) / ((cardProps.data?.download ?? 0) + (cardProps.data?.upload ?? 0))) * 100
|
||||
// 数据百分比计算
|
||||
const getMaxDataValue = computed(() => {
|
||||
// 获取站点数据中的最大值作为基准
|
||||
const upload = cardProps.data?.upload || 0
|
||||
const download = cardProps.data?.download || 0
|
||||
|
||||
// 避免两者都为0的情况
|
||||
if (upload === 0 && download === 0) return 1
|
||||
|
||||
return Math.max(upload, download)
|
||||
})
|
||||
|
||||
// 上传百分比
|
||||
const getUploadPercent = computed(() => {
|
||||
const upload = cardProps.data?.upload || 0
|
||||
return Math.min(100, Math.max(3, (upload / getMaxDataValue.value) * 100))
|
||||
})
|
||||
|
||||
// 下载百分比
|
||||
const getDownloadPercent = computed(() => {
|
||||
const download = cardProps.data?.download || 0
|
||||
return Math.min(100, Math.max(3, (download / getMaxDataValue.value) * 100))
|
||||
})
|
||||
|
||||
// 保存站点
|
||||
@@ -151,97 +169,192 @@ onMounted(() => {
|
||||
<template>
|
||||
<div>
|
||||
<VCard
|
||||
:variant="cardProps.site?.is_active ? 'elevated' : 'outlined'"
|
||||
class="overflow-hidden h-full flex flex-col"
|
||||
class="site-card relative h-full flex flex-col overflow-hidden group"
|
||||
:class="[
|
||||
cardProps.site?.is_active ? '' : 'inactive',
|
||||
{
|
||||
'status-error': statColor === 'error',
|
||||
'status-warning': statColor === 'warning',
|
||||
'status-success': statColor === 'success',
|
||||
},
|
||||
]"
|
||||
:ripple="false"
|
||||
@click="handleResourceBrowse"
|
||||
>
|
||||
<template #image>
|
||||
<VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0">
|
||||
<VImg :src="siteIcon" />
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardItem style="padding-block-end: 0">
|
||||
<VCardTitle class="font-bold">
|
||||
{{ cardProps.site?.name }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle>
|
||||
{{ cardProps.site?.url }}
|
||||
</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VCardText class="py-1">
|
||||
<VTooltip v-if="cardProps.site?.limit_interval" text="流控">
|
||||
<!-- 装饰性状态指示器 -->
|
||||
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
|
||||
|
||||
<!-- 主体部分 -->
|
||||
<div class="site-card-content relative flex-1 flex flex-col">
|
||||
<!-- 顶部:图标和站点名称 -->
|
||||
<div class="flex items-center mb-1">
|
||||
<!-- 站点图标 -->
|
||||
<div class="site-icon-container mr-2.5" @click.stop="siteEditDialog = true">
|
||||
<img :src="siteIcon" class="site-icon" :alt="cardProps.site?.name" />
|
||||
<div class="site-icon-edit-overlay">
|
||||
<VIcon icon="mdi-pencil" color="white" size="16" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 拖动图标 -->
|
||||
<VIcon icon="mdi-drag" size="20" class="drag-handle cursor-move opacity-40 mr-1.5 z-10" />
|
||||
|
||||
<!-- 站点名称和特性图标 -->
|
||||
<div class="flex-1 min-w-0 flex items-center">
|
||||
<h3 class="site-title truncate">{{ cardProps.site?.name }}</h3>
|
||||
|
||||
<!-- 站点特性图标 -->
|
||||
<div class="site-features flex items-center gap-1 ml-auto">
|
||||
<VTooltip>
|
||||
<template #activator="{ props }">
|
||||
<div v-if="cardProps.site?.limit_interval" v-bind="props" class="feature-icon-wrapper">
|
||||
<VIcon icon="mdi-speedometer" size="16" class="site-feature-icon" />
|
||||
</div>
|
||||
</template>
|
||||
<span>流控</span>
|
||||
</VTooltip>
|
||||
|
||||
<VTooltip>
|
||||
<template #activator="{ props }">
|
||||
<div v-if="cardProps.site?.proxy === 1" v-bind="props" class="feature-icon-wrapper">
|
||||
<VIcon icon="mdi-network-outline" size="16" class="site-feature-icon" />
|
||||
</div>
|
||||
</template>
|
||||
<span>代理</span>
|
||||
</VTooltip>
|
||||
|
||||
<VTooltip>
|
||||
<template #activator="{ props }">
|
||||
<div v-if="cardProps.site?.render === 1" v-bind="props" class="feature-icon-wrapper">
|
||||
<VIcon icon="mdi-apple-safari" size="16" class="site-feature-icon" />
|
||||
</div>
|
||||
</template>
|
||||
<span>仿真</span>
|
||||
</VTooltip>
|
||||
|
||||
<VTooltip>
|
||||
<template #activator="{ props }">
|
||||
<div v-if="cardProps.site?.filter" v-bind="props" class="feature-icon-wrapper">
|
||||
<VIcon icon="mdi-filter-cog-outline" size="16" class="site-feature-icon" />
|
||||
</div>
|
||||
</template>
|
||||
<span>过滤</span>
|
||||
</VTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间部分:网址 -->
|
||||
<div class="site-meta mb-1.5">
|
||||
<div class="site-url truncate" @click.stop="openSitePage">
|
||||
{{ cardProps.site?.url }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部:数据统计 -->
|
||||
<div class="site-stats flex-1 flex flex-col justify-end">
|
||||
<!-- 更直观的上传下载数据条 -->
|
||||
<div class="data-transfer-stats">
|
||||
<!-- 上传数据 -->
|
||||
<div class="data-row upload-row">
|
||||
<div class="data-label">
|
||||
<VIcon icon="mdi-arrow-up" size="14" color="info" class="mr-1" />
|
||||
<span>{{ formatFileSize(cardProps.data?.upload || 0) }}</span>
|
||||
</div>
|
||||
<div class="data-progress-bar">
|
||||
<div class="progress-filled upload-filled" :style="`width: ${getUploadPercent}%`">
|
||||
<div class="progress-glow"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 下载数据 -->
|
||||
<div class="data-row download-row">
|
||||
<div class="data-label">
|
||||
<VIcon icon="mdi-arrow-down" size="14" color="success" class="mr-1" />
|
||||
<span>{{ formatFileSize(cardProps.data?.download || 0) }}</span>
|
||||
</div>
|
||||
<div class="data-progress-bar">
|
||||
<div class="progress-filled download-filled" :style="`width: ${getDownloadPercent}%`">
|
||||
<div class="progress-glow"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧操作按钮区 -->
|
||||
<div class="site-card-actions">
|
||||
<VTooltip>
|
||||
<template #activator="{ props }">
|
||||
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-speedometer" />
|
||||
<button
|
||||
v-bind="props"
|
||||
class="site-action-btn test-btn"
|
||||
@click.stop="testSite"
|
||||
:class="{ 'testing': testButtonDisable }"
|
||||
>
|
||||
<div class="test-btn-content">
|
||||
<div class="pulse-dot" :class="statColor"></div>
|
||||
</div>
|
||||
<div v-if="testButtonDisable" class="loading-overlay">
|
||||
<div class="loading-spinner">
|
||||
<div class="spinner-circle"></div>
|
||||
<div class="spinner-circle-dot"></div>
|
||||
</div>
|
||||
<span class="loading-text">测试中</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<span>测试站点连通性</span>
|
||||
</VTooltip>
|
||||
<VTooltip v-if="cardProps.site?.proxy === 1" text="代理">
|
||||
|
||||
<VTooltip>
|
||||
<template #activator="{ props }">
|
||||
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-network-outline" />
|
||||
<button v-bind="props" class="site-action-btn" @click.stop="handleSiteUserData">
|
||||
<VIcon icon="mdi-chart-bell-curve" size="18" />
|
||||
</button>
|
||||
</template>
|
||||
<span>查看站点数据</span>
|
||||
</VTooltip>
|
||||
<VTooltip v-if="cardProps.site?.render === 1" text="浏览器仿真">
|
||||
|
||||
<VTooltip v-if="!cardProps.site?.public">
|
||||
<template #activator="{ props }">
|
||||
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-apple-safari" />
|
||||
<button v-bind="props" class="site-action-btn" @click.stop="handleSiteUpdate">
|
||||
<VIcon icon="mdi-refresh" size="18" />
|
||||
</button>
|
||||
</template>
|
||||
<span>更新Cookie/UA</span>
|
||||
</VTooltip>
|
||||
<VTooltip v-if="cardProps.site?.filter" text="过滤">
|
||||
|
||||
<VTooltip>
|
||||
<template #activator="{ props }">
|
||||
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-filter-cog-outline" />
|
||||
<button v-bind="props" class="site-action-btn more-btn">
|
||||
<VIcon icon="mdi-dots-vertical" size="18" />
|
||||
<VMenu activator="parent" close-on-content-click location="left">
|
||||
<VList density="compact" nav class="dropdown-menu">
|
||||
<VListItem variant="plain" @click.stop="siteEditDialog = true" base-color="info">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-file-edit-outline" size="small" />
|
||||
</template>
|
||||
<VListItemTitle>编辑站点</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem variant="plain" @click.stop="emit('remove')">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-delete-outline" size="small" color="error" />
|
||||
</template>
|
||||
<VListItemTitle class="text-error">删除站点</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</button>
|
||||
</template>
|
||||
<span>更多操作</span>
|
||||
</VTooltip>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-chevron-down" color="primary" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem variant="plain" @click="siteEditDialog = true" base-color="info">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-file-edit-outline" />
|
||||
</template>
|
||||
<VListItemTitle>编辑站点</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem variant="plain" @click="handleSiteUserData">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-chart-bell-curve" />
|
||||
</template>
|
||||
<VListItemTitle>查看站点数据</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem variant="plain" :disabled="testButtonDisable" @click.stop="testSite">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-link" />
|
||||
</template>
|
||||
<VListItemTitle>{{ testButtonText }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem variant="plain" v-if="!cardProps.site?.public" @click="handleSiteUpdate">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-refresh" />
|
||||
</template>
|
||||
<VListItemTitle>更新 Cookie & UA</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem variant="plain" @click="openSitePage">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-open-in-new" />
|
||||
</template>
|
||||
<VListItemTitle>访问站点</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
<span class="text-sm">
|
||||
↑ {{ formatFileSize(cardProps.data?.upload || 0) }} / ↓ {{ formatFileSize(cardProps.data?.download || 0) }}
|
||||
</span>
|
||||
<VSpacer />
|
||||
</VCardActions>
|
||||
<StatIcon v-if="cardProps.site?.is_active" :color="statColor" />
|
||||
<span class="absolute top-1 right-8">
|
||||
<VIcon class="cursor-move">mdi-drag</VIcon>
|
||||
</span>
|
||||
<div class="w-full absolute bottom-0" v-if="(cardProps.data?.upload || cardProps.data?.download || 0) > 0">
|
||||
<VProgressLinear :model-value="getPercentage" bg-color="success" color="warning" bg-opacity="0.5" height="3" />
|
||||
</div>
|
||||
</VCard>
|
||||
<!-- 更新站点Cookie & UA弹窗 -->
|
||||
|
||||
<!-- 对话框组件 -->
|
||||
<SiteCookieUpdateDialog
|
||||
v-if="siteCookieDialog"
|
||||
v-model="siteCookieDialog"
|
||||
@@ -249,7 +362,6 @@ onMounted(() => {
|
||||
@close="siteCookieDialog = false"
|
||||
@done="onSiteCookieUpdated"
|
||||
/>
|
||||
<!-- 站点编辑弹窗 -->
|
||||
<SiteAddEditDialog
|
||||
v-if="siteEditDialog"
|
||||
v-model="siteEditDialog"
|
||||
@@ -258,14 +370,12 @@ onMounted(() => {
|
||||
@remove="emit('remove')"
|
||||
@close="siteEditDialog = false"
|
||||
/>
|
||||
<!-- 站点数据弹窗 -->
|
||||
<SiteUserDataDialog
|
||||
v-if="siteUserDataDialog"
|
||||
v-model="siteUserDataDialog"
|
||||
:site="cardProps.site"
|
||||
@close="siteUserDataDialog = false"
|
||||
/>
|
||||
<!-- 站点资源弹窗 -->
|
||||
<SiteResourceDialog
|
||||
v-if="resourceDialog"
|
||||
v-model="resourceDialog"
|
||||
@@ -274,3 +384,638 @@ onMounted(() => {
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.site-card {
|
||||
background: rgba(var(--v-theme-surface), 0.95);
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.09);
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.site-card:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: rgba(var(--v-theme-primary), 0.2);
|
||||
box-shadow: 0 3px 12px -6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.inactive {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.site-card-content {
|
||||
z-index: 1;
|
||||
padding: 10px 12px 10px;
|
||||
}
|
||||
|
||||
/* 站点状态指示器 - 更精致的渐变指示 */
|
||||
.site-status-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
opacity: 0.5;
|
||||
z-index: 1;
|
||||
transition: height 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.site-status-indicator.error {
|
||||
background: linear-gradient(90deg, transparent, rgba(var(--v-theme-error), 0.7), transparent);
|
||||
box-shadow: 0 0 8px rgba(var(--v-theme-error), 0.3);
|
||||
}
|
||||
|
||||
.site-status-indicator.warning {
|
||||
background: linear-gradient(90deg, transparent, rgba(var(--v-theme-warning), 0.7), transparent);
|
||||
box-shadow: 0 0 8px rgba(var(--v-theme-warning), 0.3);
|
||||
}
|
||||
|
||||
.site-status-indicator.success {
|
||||
background: linear-gradient(90deg, transparent, rgba(var(--v-theme-success), 0.7), transparent);
|
||||
box-shadow: 0 0 8px rgba(var(--v-theme-success), 0.3);
|
||||
}
|
||||
|
||||
.site-status-indicator.secondary {
|
||||
background: linear-gradient(90deg, transparent, rgba(var(--v-theme-secondary), 0.7), transparent);
|
||||
box-shadow: 0 0 8px rgba(var(--v-theme-secondary), 0.3);
|
||||
}
|
||||
|
||||
/* 站点卡片悬停时状态指示器变化 */
|
||||
.site-card:hover .site-status-indicator {
|
||||
height: 2px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 拖动手柄 */
|
||||
.drag-handle {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 数据显示相关样式 */
|
||||
.data-transfer-stats {
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid rgba(var(--v-theme-on-surface), 0.05);
|
||||
}
|
||||
|
||||
.data-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.data-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.data-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(var(--v-theme-on-surface), 0.8);
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.data-progress-bar {
|
||||
position: relative;
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.08);
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.progress-filled {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
min-width: 3px;
|
||||
border-radius: 4px;
|
||||
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-filled {
|
||||
background: linear-gradient(90deg, #4d79ff, #0077ff);
|
||||
box-shadow: 0 0 4px rgba(0, 119, 255, 0.5);
|
||||
animation: pulse-width 2s infinite;
|
||||
}
|
||||
|
||||
.download-filled {
|
||||
background: linear-gradient(90deg, #42d392, #00b77e);
|
||||
box-shadow: 0 0 4px rgba(0, 183, 126, 0.5);
|
||||
animation: pulse-width 2s infinite;
|
||||
}
|
||||
|
||||
.progress-glow {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-width {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -100% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 速度等级样式 */
|
||||
.speed-idle {
|
||||
width: 5% !important;
|
||||
opacity: 0.5;
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.speed-low {
|
||||
width: 30% !important;
|
||||
animation-duration: 6s !important;
|
||||
}
|
||||
|
||||
.speed-medium {
|
||||
width: 50% !important;
|
||||
animation-duration: 4s !important;
|
||||
}
|
||||
|
||||
.speed-high {
|
||||
width: 70% !important;
|
||||
animation-duration: 2s !important;
|
||||
}
|
||||
|
||||
@keyframes pulse-width {
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleX(0.95);
|
||||
}
|
||||
50% {
|
||||
transform: scaleX(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 站点图标 */
|
||||
.site-icon-container {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: transform 0.2s ease;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.site-icon-container:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.site-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.site-icon-edit-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.site-icon-container:hover .site-icon-edit-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 站点标题 */
|
||||
.site-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* 站点网址 */
|
||||
.site-url {
|
||||
font-size: 0.9rem;
|
||||
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||
transition: color 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.site-url:hover {
|
||||
color: rgba(var(--v-theme-primary), 0.9);
|
||||
}
|
||||
|
||||
/* 站点特性图标 */
|
||||
.site-feature-icon {
|
||||
opacity: 0.85;
|
||||
color: rgba(var(--v-theme-primary), 0.95);
|
||||
transition: all 0.2s ease;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.05));
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
.site-feature-icon:hover {
|
||||
opacity: 1;
|
||||
transform: translateY(-1px);
|
||||
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
/* 特性标签 */
|
||||
.site-features {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* 数据统计 */
|
||||
.site-stats {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid rgba(var(--v-theme-on-surface), 0.05);
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.site-data-values {
|
||||
font-size: 12px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.8);
|
||||
}
|
||||
|
||||
.site-data-bar {
|
||||
height: 3px;
|
||||
border-radius: 1.5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.site-data-bar-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.05);
|
||||
}
|
||||
|
||||
.site-data-bar-upload {
|
||||
background-color: rgba(var(--v-theme-info), 0.4);
|
||||
}
|
||||
|
||||
.site-data-bar-download {
|
||||
background-color: rgba(var(--v-theme-success), 0.4);
|
||||
}
|
||||
|
||||
/* 状态样式 */
|
||||
.status-error {
|
||||
border-color: rgba(var(--v-theme-error), 0.2);
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
border-color: rgba(var(--v-theme-warning), 0.2);
|
||||
}
|
||||
|
||||
.status-success {
|
||||
border-color: rgba(var(--v-theme-success), 0.2);
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.site-card-actions {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 4px;
|
||||
background: rgba(var(--v-theme-surface), 0.97);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.2s ease;
|
||||
z-index: 20;
|
||||
border-left: 1px solid rgba(var(--v-theme-on-surface), 0.06);
|
||||
}
|
||||
|
||||
/* 测试按钮特殊样式 */
|
||||
.test-btn {
|
||||
width: 40px !important;
|
||||
min-width: 40px;
|
||||
height: 40px !important;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
border-radius: 50% !important;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.test-btn-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(var(--v-theme-surface), 0.95);
|
||||
border-radius: 50%;
|
||||
z-index: 10;
|
||||
animation: fade-in 0.2s ease;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
position: relative;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.spinner-circle {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 2px solid rgba(var(--v-theme-primary), 0.2);
|
||||
border-top-color: rgba(var(--v-theme-primary), 1);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.spinner-circle-dot {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
margin-left: -2px;
|
||||
margin-top: -2px;
|
||||
background-color: rgba(var(--v-theme-primary), 1);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite reverse;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
margin-top: 4px;
|
||||
color: rgba(var(--v-theme-primary), 1);
|
||||
position: absolute;
|
||||
bottom: -20px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.pulse-dot {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
background-color: transparent;
|
||||
box-shadow: inset 0 0 0 2px rgba(var(--v-theme-on-surface), 0.1);
|
||||
}
|
||||
|
||||
.pulse-dot::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 70%;
|
||||
height: 70%;
|
||||
top: 15%;
|
||||
left: 15%;
|
||||
border-radius: 50%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.pulse-dot::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-radius: 50%;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.pulse-dot.error::before {
|
||||
background-color: rgba(var(--v-theme-error), 1);
|
||||
box-shadow: 0 0 10px rgba(var(--v-theme-error), 0.8);
|
||||
}
|
||||
|
||||
.pulse-dot.error::after {
|
||||
box-shadow: 0 0 0 2px rgba(var(--v-theme-error), 0.3);
|
||||
animation: pulse-animation-error 2s infinite;
|
||||
}
|
||||
|
||||
.pulse-dot.warning::before {
|
||||
background-color: rgba(var(--v-theme-warning), 1);
|
||||
box-shadow: 0 0 10px rgba(var(--v-theme-warning), 0.8);
|
||||
}
|
||||
|
||||
.pulse-dot.warning::after {
|
||||
box-shadow: 0 0 0 2px rgba(var(--v-theme-warning), 0.3);
|
||||
animation: pulse-animation-warning 2s infinite;
|
||||
}
|
||||
|
||||
.pulse-dot.success::before {
|
||||
background-color: rgba(var(--v-theme-success), 1);
|
||||
box-shadow: 0 0 10px rgba(var(--v-theme-success), 0.8);
|
||||
}
|
||||
|
||||
.pulse-dot.success::after {
|
||||
box-shadow: 0 0 0 2px rgba(var(--v-theme-success), 0.3);
|
||||
animation: pulse-animation-success 2s infinite;
|
||||
}
|
||||
|
||||
.pulse-dot.secondary::before {
|
||||
background-color: rgba(var(--v-theme-secondary), 1);
|
||||
box-shadow: 0 0 10px rgba(var(--v-theme-secondary), 0.8);
|
||||
}
|
||||
|
||||
.pulse-dot.secondary::after {
|
||||
box-shadow: 0 0 0 2px rgba(var(--v-theme-secondary), 0.3);
|
||||
animation: pulse-animation-secondary 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-animation-error {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(var(--v-theme-error), 0.6);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(var(--v-theme-error), 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(var(--v-theme-error), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-animation-warning {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(var(--v-theme-warning), 0.6);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(var(--v-theme-warning), 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(var(--v-theme-warning), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-animation-success {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(var(--v-theme-success), 0.6);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(var(--v-theme-success), 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(var(--v-theme-success), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-animation-secondary {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(var(--v-theme-secondary), 0.6);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(var(--v-theme-secondary), 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(var(--v-theme-secondary), 0);
|
||||
}
|
||||
}
|
||||
|
||||
.site-card:hover .site-card-actions {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.site-action-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background-color: rgba(var(--v-theme-surface), 1);
|
||||
color: rgba(var(--v-theme-on-surface), 0.8);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.site-action-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at center, rgba(var(--v-theme-primary), 0.1), transparent 70%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.site-action-btn:hover {
|
||||
background-color: white;
|
||||
color: rgba(var(--v-theme-primary), 1);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.site-action-btn:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.site-action-btn.animate-pulse {
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(var(--v-theme-primary), 0.4);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px rgba(var(--v-theme-primary), 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(var(--v-theme-primary), 0);
|
||||
}
|
||||
}
|
||||
|
||||
.site-action-btn.more-btn {
|
||||
margin-bottom: 0;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.feature-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.feature-icon-wrapper:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -295,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>
|
||||
@@ -420,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%);
|
||||
}
|
||||
|
||||
@@ -100,9 +100,9 @@ function doDelete() {
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col rounded-lg h-full"
|
||||
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"
|
||||
@@ -177,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,291 @@ onMounted(() => {
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-item-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.torrent-item {
|
||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||
margin-bottom: 8px;
|
||||
padding: 12px;
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.torrent-item:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.04);
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
}
|
||||
|
||||
.site-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 100px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.site-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.site-fallback {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.1);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
font-weight: 700;
|
||||
margin-right: 8px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
margin-right: 8px;
|
||||
font-weight: 600;
|
||||
color: rgba(var(--v-theme-on-surface), 0.8);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.season-tag {
|
||||
font-size: 0.875rem;
|
||||
background-color: #5c6bc0;
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
margin-right: 8px;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.free-tag {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.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;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.media-info {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.media-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: rgba(var(--v-theme-on-surface), 0.87);
|
||||
}
|
||||
|
||||
.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 {
|
||||
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;
|
||||
}
|
||||
|
||||
.size-badge {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
background-color: rgba(var(--v-theme-primary), 0.1);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.torrent-title {
|
||||
font-size: 0.9rem;
|
||||
color: rgba(var(--v-theme-on-surface), 0.87);
|
||||
margin-bottom: 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.torrent-description {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(var(--v-theme-on-surface), 0.65);
|
||||
margin-bottom: 8px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tags-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.detail-btn {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.downloaded-item {
|
||||
border-left: 4px solid #4caf50;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.break-words {
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.overflow-visible {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.whitespace-break-spaces {
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.torrent-item {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.site-icon,
|
||||
.site-fallback {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.site-wrapper {
|
||||
min-width: 24px;
|
||||
flex-wrap: wrap;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
font-size: 0.8rem;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.size-badge {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.resource-tag {
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.torrent-description {
|
||||
max-width: 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>
|
||||
|
||||
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>
|
||||
@@ -166,7 +166,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="50rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard
|
||||
:title="`${props.oper === 'add' ? '新增' : '编辑'}站点${props.oper !== 'add' ? ` - ${siteForm.name}` : ''}`"
|
||||
class="rounded-t"
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -198,7 +198,7 @@ onBeforeMount(() => {
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
<style lang="scss" scoped>
|
||||
.vue-media-back {
|
||||
background-image: linear-gradient(
|
||||
180deg,
|
||||
|
||||
@@ -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="50rem" :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"
|
||||
|
||||
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,11 @@ const inProps = defineProps({
|
||||
required: true,
|
||||
},
|
||||
sort: String,
|
||||
listStyle: String,
|
||||
})
|
||||
|
||||
// 对外事件
|
||||
const emit = defineEmits(['loading', 'pathchanged', 'refreshed', 'filedeleted', 'renamed'])
|
||||
const emit = defineEmits(['loading', 'pathchanged', 'refreshed', 'filedeleted', 'renamed', 'items-updated'])
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
@@ -112,13 +110,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 +140,9 @@ async function list_files() {
|
||||
items.value = (await inProps.axios.request(config)) ?? []
|
||||
emit('loading', false)
|
||||
loading.value = false
|
||||
|
||||
// 通知父组件文件列表更新
|
||||
emit('items-updated', items.value)
|
||||
}
|
||||
|
||||
// 删除项目
|
||||
@@ -539,7 +533,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="d-flex flex-column">
|
||||
<VCard class="d-flex flex-column w-full h-full">
|
||||
<VToolbar v-if="!loading" density="compact" flat color="gray">
|
||||
<VTextField
|
||||
v-if="!isFile"
|
||||
@@ -551,7 +545,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 +599,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 +613,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>
|
||||
|
||||
483
src/components/filebrowser/FileNavigator.vue
Normal file
483
src/components/filebrowser/FileNavigator.vue
Normal file
@@ -0,0 +1,483 @@
|
||||
<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" 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 {
|
||||
width: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
border-bottom-left-radius: 12px;
|
||||
background: rgb(var(--v-table-header-background));
|
||||
}
|
||||
|
||||
.navigator-header {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.tree-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.tree-item-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tree-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.folder-toggle {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 4px;
|
||||
flex-shrink: 0;
|
||||
padding: 6px 0px 6px 12px;
|
||||
}
|
||||
|
||||
.folder-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
padding: 6px 16px 6px 8px;
|
||||
}
|
||||
|
||||
.root-item {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.folder-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 150px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.subdirectory-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tree-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 16px;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
}
|
||||
|
||||
.pl-8 {
|
||||
padding-left: 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 }">
|
||||
@@ -169,7 +169,7 @@ const sortIcon = computed(() => {
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VDialog v-if="newFolderPopper" v-model="newFolderPopper" max-width="50rem">
|
||||
<VDialog v-model="newFolderPopper" max-width="50rem">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props">
|
||||
<VTooltip text="新建文件夹">
|
||||
|
||||
@@ -49,9 +49,28 @@ const parseProps = (rawProps: Record<string, any>, model: Record<string, any>) =
|
||||
model[value] = newValue
|
||||
}
|
||||
} else if (key.startsWith('on')) {
|
||||
// 处理事件监听,值是函数的代码
|
||||
const eventName = key.replace('on', '').toLowerCase()
|
||||
parsedProps[eventName] = new Function('model', `with(model) { return ${value} }`)(model)
|
||||
// 处理事件监听,值是函数的代码 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 {
|
||||
// 如果是表达式,需要绑定
|
||||
if (typeof value === 'string' && isExpression(value)) {
|
||||
|
||||
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[]>([])
|
||||
|
||||
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>
|
||||
@@ -61,8 +61,7 @@ const currentPath = computed(() => route.path)
|
||||
:color="moreActiveState ? 'primary' : ''"
|
||||
/>
|
||||
<VMenu v-model="moreMenuDialog" close-on-content-click activator="parent">
|
||||
<VDivider />
|
||||
<VList class="font-bold" lines="one">
|
||||
<VList class="font-bold" lines="one" elevation="1">
|
||||
<VListSubheader class="bg-transparent"> 更多 </VListSubheader>
|
||||
<VListItem
|
||||
class="pe-20"
|
||||
|
||||
@@ -118,7 +118,7 @@ onMounted(() => {
|
||||
</IconBtn>
|
||||
</template>
|
||||
<!-- Menu Content -->
|
||||
<VCard>
|
||||
<VCard elevation="1">
|
||||
<VCardItem class="border-b">
|
||||
<VCardTitle>捷径</VCardTitle>
|
||||
<template #append>
|
||||
@@ -138,7 +138,7 @@ onMounted(() => {
|
||||
<span class="text-sm">名称识别测试</span>
|
||||
</VListItem>
|
||||
</VCol>
|
||||
<VCol cols="6" class="text-center cursor-pointer pa-0 shortcut-icon border-e" @click="() => {}">
|
||||
<VCol cols="6" class="text-center cursor-pointer pa-0 shortcut-icon" @click="() => {}">
|
||||
<VListItem class="pa-4" @click="ruleTestDialog = true">
|
||||
<VAvatar size="48" variant="tonal">
|
||||
<VIcon icon="mdi-filter-cog-outline" />
|
||||
@@ -178,7 +178,7 @@ onMounted(() => {
|
||||
<span class="text-sm">健康检查</span>
|
||||
</VListItem>
|
||||
</VCol>
|
||||
<VCol cols="6" class="text-center cursor-pointer pa-0 shortcut-icon border-e" @click="() => {}">
|
||||
<VCol cols="6" class="text-center cursor-pointer pa-0 shortcut-icon" @click="() => {}">
|
||||
<VListItem class="pa-4" @click="messageDialog = true">
|
||||
<VAvatar size="48" variant="tonal">
|
||||
<VIcon icon="mdi-message-outline" />
|
||||
@@ -262,7 +262,7 @@ onMounted(() => {
|
||||
<VDialog
|
||||
v-if="messageDialog"
|
||||
v-model="messageDialog"
|
||||
max-width="60rem"
|
||||
max-width="45rem"
|
||||
scrollable
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
|
||||
@@ -24,7 +24,6 @@ function startSSEMessager() {
|
||||
const noti: SystemNotification = JSON.parse(event.data)
|
||||
notificationList.value.unshift(noti)
|
||||
hasNewMessage.value = true
|
||||
// TODO 在顶部显示消息汽泡
|
||||
}
|
||||
})
|
||||
}, 3000)
|
||||
@@ -54,7 +53,7 @@ onBeforeUnmount(() => {
|
||||
</IconBtn>
|
||||
</template>
|
||||
<!-- Menu Content -->
|
||||
<VCard>
|
||||
<VCard elevation="1">
|
||||
<VCardItem class="border-b">
|
||||
<VCardTitle>通知</VCardTitle>
|
||||
<template #append>
|
||||
|
||||
@@ -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,11 +74,11 @@ 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>
|
||||
@@ -86,7 +86,7 @@ const userLevel = computed(() => store.state.auth.level)
|
||||
<VImg :src="avatar" />
|
||||
|
||||
<VMenu activator="parent" width="230" location="bottom end" offset="14px">
|
||||
<VList>
|
||||
<VList elevation="1">
|
||||
<!-- 👉 User Avatar & Name -->
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
|
||||
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>
|
||||
20
src/main.ts
20
src/main.ts
@@ -8,7 +8,7 @@ import '@/plugins/webfontloader'
|
||||
import { createApp } from 'vue'
|
||||
import vuetify from '@/plugins/vuetify'
|
||||
import router from '@/router'
|
||||
import store from '@/store'
|
||||
import pinia from '@/stores/index'
|
||||
|
||||
// 3. 全局组件
|
||||
import App from '@/App.vue'
|
||||
@@ -50,11 +50,16 @@ import '@styles/styles.scss'
|
||||
// 创建Vue实例
|
||||
const app = createApp(App)
|
||||
|
||||
// 注册pinia
|
||||
app.use(pinia)
|
||||
|
||||
// 初始化配置
|
||||
async function initializeApp() {
|
||||
try {
|
||||
// 是否为PWA
|
||||
const pwaMode = await isPWA()
|
||||
app.provide('pwaMode', pwaMode)
|
||||
|
||||
// 全局设置
|
||||
const globalSettings = await fetchGlobalSettings()
|
||||
app.provide('globalSettings', globalSettings)
|
||||
@@ -65,10 +70,13 @@ async function initializeApp() {
|
||||
|
||||
// 注册全局组件
|
||||
initializeApp().then(() => {
|
||||
// 优先注册框架
|
||||
// 1. 注册 UI 框架
|
||||
app.use(vuetify)
|
||||
|
||||
// 注册全局组件
|
||||
// 2. 注册路由
|
||||
app.use(router)
|
||||
|
||||
// 3. 注册全局组件
|
||||
app
|
||||
.component('VAceEditor', VAceEditor)
|
||||
.component('VApexChart', VueApexCharts)
|
||||
@@ -84,10 +92,8 @@ initializeApp().then(() => {
|
||||
.component('VCronField', CronField)
|
||||
.component('VPathField', PathField)
|
||||
|
||||
// 注册插件
|
||||
// 5. 注册其他插件
|
||||
app
|
||||
.use(router)
|
||||
.use(store)
|
||||
.use(PerfectScrollbarPlugin)
|
||||
.use(ToastPlugin, {
|
||||
position: 'bottom-right',
|
||||
@@ -95,7 +101,7 @@ initializeApp().then(() => {
|
||||
.use(VuetifyUseDialog, {
|
||||
confirmDialog: {
|
||||
dialogProps: {
|
||||
maxWidth: '40rem',
|
||||
maxWidth: '30rem',
|
||||
},
|
||||
confirmationButtonProps: {
|
||||
variant: 'elevated',
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { NavMenu } from '@/@layouts/types'
|
||||
import { SystemNavMenus } from '@/router/menu'
|
||||
import store from '@/store'
|
||||
import { useUserStore } from '@/stores'
|
||||
import draggable from 'vuedraggable'
|
||||
|
||||
// 从Vuex Store中获取superuser信息
|
||||
const superUser = store.state.auth.superUser
|
||||
// 从 Store 中获取superuser信息
|
||||
const superUser = useUserStore().superUser
|
||||
|
||||
// APP图标顺序
|
||||
const appOrder = ref<string[]>([])
|
||||
@@ -61,7 +61,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style type="scss">
|
||||
<style type="scss" scoped>
|
||||
.appcenter-grid .v-card {
|
||||
/* stylelint-disable-next-line property-no-vendor-prefix */
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
|
||||
@@ -27,10 +27,10 @@ function getApiPath(paths: string[] | string) {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="title" class="mt-3 md:flex md:items-center md:justify-between">
|
||||
<div v-if="title" class="my-3 md:flex md:items-center md:justify-between">
|
||||
<div class="min-w-0 flex-1 mx-0">
|
||||
<h2
|
||||
class="mb-4 ms-3 truncate text-2xl font-bold leading-7 text-gray-100 sm:overflow-visible sm:text-4xl sm:leading-9 md:mb-0"
|
||||
class="ms-1 truncate text-2xl font-bold leading-7 text-gray-100 sm:overflow-visible sm:text-3xl sm:leading-9 md:mb-0"
|
||||
data-testid="page-header"
|
||||
>
|
||||
<span class="text-moviepilot">{{ title }}</span>
|
||||
|
||||
@@ -3,7 +3,7 @@ import draggable from 'vuedraggable'
|
||||
import api from '@/api'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { DashboardItem } from '@/api/types'
|
||||
import store from '@/store'
|
||||
import { useUserStore } from '@/stores'
|
||||
import DashboardElement from '@/components/misc/DashboardElement.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
@@ -11,8 +11,8 @@ import { useDisplay } from 'vuetify'
|
||||
const display = useDisplay()
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
|
||||
// 从Vuex Store中获取superuser信息
|
||||
const superUser = store.state.auth.superUser
|
||||
// 从用户 Store 中获取superuser信息
|
||||
const superUser = useUserStore().superUser
|
||||
|
||||
// 是否拉升高度
|
||||
const isElevated = ref(true)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { DiscoverTabs } from '@/router/menu'
|
||||
import router from '@/router'
|
||||
import draggable from 'vuedraggable'
|
||||
import TheMovieDbView from '@/views/discover/TheMovieDbView.vue'
|
||||
import DoubanView from '@/views/discover/DoubanView.vue'
|
||||
import BangumiView from '@/views/discover/BangumiView.vue'
|
||||
@@ -8,52 +8,124 @@ import ExtraSourceView from '@/views/discover/ExtraSourceView.vue'
|
||||
import { DiscoverSource } from '@/api/types'
|
||||
import api from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const activeTab = ref(route.query.tab)
|
||||
const activeTab = ref('')
|
||||
|
||||
function jumpTab(tab: string) {
|
||||
router.push('/subscribe/discover?tab=' + tab)
|
||||
}
|
||||
// 本地存储键值
|
||||
const localOrderKey = 'MP_DISCOVER_TAB_ORDER'
|
||||
|
||||
// 顺序配置
|
||||
const orderConfig = ref<{ name: string }[]>([])
|
||||
|
||||
// 标签页
|
||||
const discoverTabs = ref<DiscoverSource[]>([])
|
||||
|
||||
// 额外的数据源
|
||||
const extraDiscoverSources = ref<DiscoverSource[]>([])
|
||||
|
||||
// 初始化发现标签
|
||||
function initDiscoverTabs() {
|
||||
for (const tab of DiscoverTabs) {
|
||||
discoverTabs.value.push({
|
||||
name: tab.name,
|
||||
mediaid_prefix: tab.tab,
|
||||
api_path: '',
|
||||
filter_params: {},
|
||||
filter_ui: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 加载额外的发现数据源
|
||||
async function loadExtraDiscoverSources() {
|
||||
try {
|
||||
extraDiscoverSources.value = await api.get('discover/source')
|
||||
if (extraDiscoverSources.value.length === 0) {
|
||||
return
|
||||
}
|
||||
for (const source of extraDiscoverSources.value) {
|
||||
if (discoverTabs.value.find(tab => tab.mediaid_prefix === source.mediaid_prefix)) {
|
||||
continue
|
||||
}
|
||||
discoverTabs.value.push(source)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 按order的顺序排序
|
||||
function sortSubscribeOrder() {
|
||||
if (!orderConfig.value) {
|
||||
return
|
||||
}
|
||||
if (discoverTabs.value.length === 0) {
|
||||
return
|
||||
}
|
||||
discoverTabs.value.sort((a, b) => {
|
||||
const aIndex = orderConfig.value.findIndex((item: { name: string }) => item.name === a.name)
|
||||
const bIndex = orderConfig.value.findIndex((item: { name: string }) => item.name === b.name)
|
||||
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex)
|
||||
})
|
||||
}
|
||||
|
||||
// 加载顺序
|
||||
async function loadOrderConfig() {
|
||||
// 顺序配置
|
||||
const local_order = localStorage.getItem(localOrderKey)
|
||||
if (local_order) {
|
||||
orderConfig.value = JSON.parse(local_order)
|
||||
} else {
|
||||
const response = await api.get(`/user/config/${localOrderKey}`)
|
||||
if (response && response.data && response.data.value) {
|
||||
orderConfig.value = response.data.value
|
||||
localStorage.setItem(localOrderKey, JSON.stringify(orderConfig.value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存顺序设置
|
||||
async function saveTabOrder() {
|
||||
// 顺序配置
|
||||
const orderObj = discoverTabs.value.map(item => ({ name: item.name }))
|
||||
orderConfig.value = orderObj
|
||||
const orderString = JSON.stringify(orderObj)
|
||||
localStorage.setItem(localOrderKey, orderString)
|
||||
|
||||
// 保存到服务端
|
||||
try {
|
||||
await api.post(`/user/config/${localOrderKey}`, orderObj)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeMount(async () => {
|
||||
initDiscoverTabs()
|
||||
await loadOrderConfig()
|
||||
await loadExtraDiscoverSources()
|
||||
sortSubscribeOrder()
|
||||
// 选中第一个标签页
|
||||
if (discoverTabs.value.length > 0) {
|
||||
activeTab.value = discoverTabs.value[0].mediaid_prefix
|
||||
}
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
loadExtraDiscoverSources()
|
||||
await loadExtraDiscoverSources()
|
||||
sortSubscribeOrder()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VTabs v-model="activeTab" show-arrows>
|
||||
<VTab v-for="item in DiscoverTabs" :value="item.tab" @to="jumpTab(item.tab)">
|
||||
<div class="min-w-24">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
</VTab>
|
||||
<VTab
|
||||
v-for="item in extraDiscoverSources"
|
||||
:key="item.mediaid_prefix"
|
||||
:value="item.mediaid_prefix"
|
||||
@to="jumpTab(item.mediaid_prefix)"
|
||||
>
|
||||
<div class="min-w-24">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
</VTab>
|
||||
<VTabs v-model="activeTab" show-arrows stacked>
|
||||
<draggable v-model="discoverTabs" handle=".tab-move" item-key="tab" tag="div" @end="saveTabOrder">
|
||||
<template #item="{ element }">
|
||||
<VTab :key="element.mediaid_prefix" :value="element.mediaid_prefix" class="px-10 rounded-t-lg">
|
||||
<span class="tab-move">{{ element.name }}</span>
|
||||
</VTab>
|
||||
</template>
|
||||
</draggable>
|
||||
</VTabs>
|
||||
|
||||
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
|
||||
|
||||
@@ -37,9 +37,9 @@ onActivated(async () => {
|
||||
|
||||
<template>
|
||||
<div v-if="downloaders.length > 0">
|
||||
<VTabs v-model="activeTab">
|
||||
<VTab v-for="item in downloaders" :value="item.name" @to="jumpTab(item.name)">
|
||||
<span class="min-w-24">{{ item.name }}</span>
|
||||
<VTabs v-model="activeTab" show-arrows stacked>
|
||||
<VTab v-for="item in downloaders" :value="item.name" @to="jumpTab(item.name)" class="px-10 rounded-t-lg">
|
||||
{{ item.name }}
|
||||
</VTab>
|
||||
</VTabs>
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { debounce } from 'lodash'
|
||||
import { debounce } from 'lodash-es'
|
||||
import { VForm } from 'vuetify/components/VForm'
|
||||
import { useStore } from 'vuex'
|
||||
import { useAuthStore, useUserStore } from '@/stores'
|
||||
import { authState, userState } from '@/stores/types'
|
||||
import { requiredValidator } from '@/@validators'
|
||||
import api from '@/api'
|
||||
import router from '@/router'
|
||||
@@ -11,10 +12,12 @@ import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
|
||||
import { urlBase64ToUint8Array } from '@/@core/utils/navigator'
|
||||
import { saveLocalTheme } from '@/@core/utils/theme'
|
||||
|
||||
// 主题
|
||||
const { global: globalTheme } = useTheme()
|
||||
|
||||
// Vuex Store
|
||||
const store = useStore()
|
||||
// 认证 Store
|
||||
const authStore = useAuthStore()
|
||||
//用户 Store
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 表单
|
||||
const form = ref({
|
||||
@@ -119,7 +122,7 @@ async function afterLogin(superuser: boolean) {
|
||||
// 生效主题配置
|
||||
await setTheme()
|
||||
// 跳转到首页或回原始页面
|
||||
router.push(store.state.auth.originalPath ?? '/')
|
||||
router.push(authStore.originalPath ?? '/')
|
||||
// 订阅推送通知
|
||||
if (superuser) await subscribeForPushNotifications()
|
||||
}
|
||||
@@ -147,30 +150,25 @@ function login() {
|
||||
},
|
||||
})
|
||||
.then((response: any) => {
|
||||
// 获取token
|
||||
const token = response.access_token
|
||||
const superUser = response.super_user
|
||||
const userID = response.user_id
|
||||
const userName = response.user_name
|
||||
const avatar = response.avatar
|
||||
const level = response.level
|
||||
const remember = form.value.remember
|
||||
const permissions = response.permissions
|
||||
const authPayLoad: authState = {
|
||||
token: response.access_token,
|
||||
remember: form.value.remember,
|
||||
}
|
||||
|
||||
// 更新token和remember状态到Vuex Store
|
||||
store.dispatch('auth/login', {
|
||||
token,
|
||||
remember,
|
||||
superUser,
|
||||
userID,
|
||||
userName,
|
||||
avatar,
|
||||
level,
|
||||
permissions,
|
||||
})
|
||||
const userPayload: userState = {
|
||||
superUser: response.super_user,
|
||||
userID: response.user_id,
|
||||
userName: response.user_name,
|
||||
avatar: response.avatar,
|
||||
level: response.level,
|
||||
permissions: response.permissions,
|
||||
}
|
||||
|
||||
authStore.login(authPayLoad)
|
||||
userStore.loginUser(userPayload)
|
||||
|
||||
// 登录后处理
|
||||
afterLogin(superUser)
|
||||
afterLogin(userPayload.superUser)
|
||||
})
|
||||
.catch((error: any) => {
|
||||
// 登录失败,显示错误提示
|
||||
@@ -191,9 +189,9 @@ function startBackgroundRotation() {
|
||||
|
||||
// 自动登录
|
||||
onMounted(async () => {
|
||||
// 从Vuex Store中获取token和remember状态
|
||||
const token = store.state.auth.token
|
||||
const remember = store.state.auth.remember
|
||||
// 获取token和remember状态
|
||||
const token = authStore.token
|
||||
const remember = authStore.remember
|
||||
|
||||
// 如果token存在,且保持登录状态为true,则跳转到首页
|
||||
if (token && remember) {
|
||||
@@ -230,7 +228,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<!-- 登录表单 -->
|
||||
<div class="auth-wrapper d-flex align-center justify-center">
|
||||
<VCard class="auth-card px-7 py-3 w-full h-full rounded-lg opacity-85" max-width="24rem">
|
||||
<VCard class="auth-card px-7 py-3 w-full h-full opacity-85" max-width="24rem">
|
||||
<VCardItem class="justify-center">
|
||||
<template #prepend>
|
||||
<div class="d-flex pe-0">
|
||||
@@ -290,8 +288,8 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@use '@core/scss/pages/page-auth.scss';
|
||||
<style lang="scss" scoped>
|
||||
@use '@core/scss/pages/page-auth';
|
||||
|
||||
.v-card-item__prepend {
|
||||
padding-inline-end: 0 !important;
|
||||
|
||||
@@ -20,8 +20,8 @@ const viewList = reactive<{ apipath: string; linkurl: string; title: string }[]>
|
||||
title: '正在热映',
|
||||
},
|
||||
{
|
||||
apipath: 'bangumi/calendar',
|
||||
linkurl: '/browse/bangumi/calendar?title=Bangumi每日放送',
|
||||
apipath: 'recommend/bangumi_calendar',
|
||||
linkurl: '/browse/recommend/bangumi_calendar?title=Bangumi每日放送',
|
||||
title: 'Bangumi每日放送',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,11 +4,6 @@ import api from '@/api'
|
||||
import type { Context } from '@/api/types'
|
||||
import TorrentCardListView from '@/views/torrent/TorrentCardListView.vue'
|
||||
import TorrentRowListView from '@/views/torrent/TorrentRowListView.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// APP
|
||||
const display = useDisplay()
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
|
||||
// 路由参数
|
||||
const route = useRoute()
|
||||
@@ -31,9 +26,15 @@ const year = route.query?.year
|
||||
// 搜索季
|
||||
const season = route.query?.season?.toString() ?? ''
|
||||
|
||||
// 搜索站点,以,分离多个
|
||||
const sites = route.query?.sites?.toString() ?? ''
|
||||
|
||||
// 视图类型,从localStorage中读取
|
||||
const viewType = ref<string>(localStorage.getItem('MPTorrentsViewType') ?? 'card')
|
||||
|
||||
// 视图切换中
|
||||
const isViewChanging = ref(false)
|
||||
|
||||
// 数据列表
|
||||
const dataList = ref<Array<Context>>([])
|
||||
|
||||
@@ -58,25 +59,63 @@ const errorDescription = ref('未搜索到任何资源')
|
||||
// 使用SSE监听加载进度
|
||||
function startLoadingProgress() {
|
||||
progressText.value = '正在搜索,请稍候...'
|
||||
progressValue.value = 10 // 初始进度设为10%,确保进度条显示
|
||||
progressEventSource.value = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/progress/search`)
|
||||
progressEventSource.value.onmessage = event => {
|
||||
const progress = JSON.parse(event.data)
|
||||
if (progress) {
|
||||
progressText.value = progress.text
|
||||
progressValue.value = progress.value
|
||||
|
||||
// 搜索完成条件调整:只有明确完成时才关闭
|
||||
if (progress.text.includes('完成') && progress.value >= 99) {
|
||||
setTimeout(() => {
|
||||
stopLoadingProgress()
|
||||
}, 1000) // 延迟1秒关闭,确保用户能看到100%
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加错误处理
|
||||
progressEventSource.value.onerror = () => {
|
||||
setTimeout(() => {
|
||||
stopLoadingProgress()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// 添加安全超时,确保不会永远卡住
|
||||
setTimeout(() => {
|
||||
if (progressEventSource.value && progressValue.value < 100) {
|
||||
stopLoadingProgress()
|
||||
}
|
||||
}, 60000) // 60秒超时
|
||||
}
|
||||
|
||||
// 停止监听加载进度
|
||||
function stopLoadingProgress() {
|
||||
if (progressEventSource.value) progressEventSource.value?.close()
|
||||
if (progressEventSource.value) {
|
||||
progressEventSource.value.close()
|
||||
progressEventSource.value = undefined
|
||||
}
|
||||
// 确保进度显示100%,然后再渐进清零
|
||||
progressValue.value = 100
|
||||
setTimeout(() => {
|
||||
progressValue.value = 0
|
||||
}, 1500) // 延长到1.5秒,让用户有足够时间看到完成状态
|
||||
}
|
||||
|
||||
// 设置视图类型
|
||||
function setViewType(type: string) {
|
||||
localStorage.setItem('MPTorrentsViewType', type)
|
||||
viewType.value = type
|
||||
function changeViewType(newType: string) {
|
||||
if (viewType.value !== newType) {
|
||||
isViewChanging.value = true
|
||||
viewType.value = newType
|
||||
localStorage.setItem('MPTorrentsViewType', newType)
|
||||
|
||||
// 模拟视图切换的加载过程
|
||||
setTimeout(() => {
|
||||
isViewChanging.value = false
|
||||
}, 600)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取搜索列表数据
|
||||
@@ -97,6 +136,7 @@ async function fetchData() {
|
||||
title,
|
||||
year,
|
||||
season,
|
||||
sites,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
@@ -104,11 +144,12 @@ async function fetchData() {
|
||||
result = await api.get(`search/title`, {
|
||||
params: {
|
||||
keyword,
|
||||
sites,
|
||||
},
|
||||
})
|
||||
}
|
||||
if (result && result.success) {
|
||||
dataList.value = result.data
|
||||
dataList.value = result.data || []
|
||||
} else if (result && result.message) {
|
||||
errorDescription.value = result.message
|
||||
}
|
||||
@@ -120,6 +161,8 @@ async function fetchData() {
|
||||
isRefreshed.value = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
stopLoadingProgress()
|
||||
isRefreshed.value = true
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
@@ -136,39 +179,397 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" :text="progressText" :progress="progressValue" />
|
||||
<NoDataFound
|
||||
v-if="dataList.length === 0 && isRefreshed"
|
||||
:error-title="errorTitle"
|
||||
:error-description="errorDescription"
|
||||
/>
|
||||
<div v-if="dataList.length > 0">
|
||||
<TorrentRowListView v-if="viewType === 'list'" :items="dataList" />
|
||||
<TorrentCardListView v-else :items="dataList" />
|
||||
</div>
|
||||
<!-- 视图切换 -->
|
||||
<div v-if="isRefreshed">
|
||||
<VFab
|
||||
v-if="viewType === 'list'"
|
||||
icon="mdi-view-grid"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
absolute
|
||||
app
|
||||
appear
|
||||
@click="setViewType('card')"
|
||||
:class="{ 'mb-12': appMode }"
|
||||
/>
|
||||
<VFab
|
||||
v-else
|
||||
icon="mdi-view-list"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="setViewType('list')"
|
||||
:class="{ 'mb-12': appMode }"
|
||||
/>
|
||||
<div>
|
||||
<!-- 加载进度条 -->
|
||||
<VFadeTransition>
|
||||
<div v-if="progressValue > 0" class="search-progress-container">
|
||||
<div class="search-progress-card">
|
||||
<div class="progress-header">
|
||||
<VIcon icon="mdi-movie-search" color="primary" size="small" class="me-2" />
|
||||
<span class="progress-title">{{ progressText }}</span>
|
||||
</div>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar-wrapper">
|
||||
<div class="progress-bar" :style="{ width: `${progressValue}%` }"></div>
|
||||
</div>
|
||||
<div class="progress-percentage">{{ Math.ceil(progressValue) }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VFadeTransition>
|
||||
|
||||
<!-- 精简标题栏 -->
|
||||
<VCard v-if="isRefreshed" class="search-header d-flex align-center mb-4">
|
||||
<div class="search-info-container d-flex align-center flex-wrap">
|
||||
<div class="search-title text-primary">资源搜索结果</div>
|
||||
<div class="search-tags d-flex flex-wrap">
|
||||
<VChip v-if="keyword" class="search-tag" color="primary" size="small" variant="flat">
|
||||
关键词: {{ keyword }}
|
||||
</VChip>
|
||||
<VChip v-if="title" class="search-tag" color="primary" size="small" variant="flat"> 标题: {{ title }} </VChip>
|
||||
<VChip v-if="year" class="search-tag" color="primary" size="small" variant="flat"> 年份: {{ year }} </VChip>
|
||||
<VChip v-if="season" class="search-tag" color="primary" size="small" variant="flat"> 季: {{ season }} </VChip>
|
||||
</div>
|
||||
</div>
|
||||
<VSpacer />
|
||||
<!-- 重新设计的视图切换按钮 -->
|
||||
<div class="view-toggle-container">
|
||||
<div class="view-toggle-buttons">
|
||||
<button class="view-toggle-btn" :class="{ active: viewType === 'card' }" @click="changeViewType('card')">
|
||||
<VIcon icon="mdi-view-grid-outline" :color="viewType === 'card' ? 'primary' : undefined" />
|
||||
</button>
|
||||
<button class="view-toggle-btn" :class="{ active: viewType === 'row' }" @click="changeViewType('row')">
|
||||
<VIcon icon="mdi-view-list-outline" :color="viewType === 'row' ? 'primary' : undefined" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<!-- 视图切换加载状态 -->
|
||||
<VFadeTransition>
|
||||
<div v-if="isRefreshed && isViewChanging" class="view-changing-container">
|
||||
<div class="view-changing-content">
|
||||
<div class="pulse-loader">
|
||||
<div class="pulse-circle"></div>
|
||||
<div class="pulse-circle"></div>
|
||||
<div class="pulse-circle"></div>
|
||||
</div>
|
||||
<div class="view-changing-text">切换视图</div>
|
||||
</div>
|
||||
</div>
|
||||
</VFadeTransition>
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<div v-if="isRefreshed && dataList.length > 0 && !isViewChanging" class="search-results-container">
|
||||
<!-- 卡片视图模式 -->
|
||||
<VFadeTransition>
|
||||
<TorrentCardListView v-if="viewType === 'card'" :items="dataList" />
|
||||
</VFadeTransition>
|
||||
|
||||
<!-- 列表视图模式 -->
|
||||
<VFadeTransition>
|
||||
<TorrentRowListView v-if="viewType === 'row'" :items="dataList" />
|
||||
</VFadeTransition>
|
||||
</div>
|
||||
|
||||
<!-- 无数据显示 -->
|
||||
<div v-else-if="isRefreshed && !isViewChanging" class="d-flex flex-column align-center justify-center py-8">
|
||||
<NoDataFound :errorTitle="errorTitle" :errorDescription="errorDescription" />
|
||||
<VBtn class="mt-4" color="primary" prepend-icon="mdi-magnify" to="/"> 返回首页 </VBtn>
|
||||
</div>
|
||||
|
||||
<!-- 初始加载状态 -->
|
||||
<div v-else-if="!isRefreshed && !progressValue" class="initial-loading-container">
|
||||
<div class="initial-loading-content">
|
||||
<div class="wave-loader">
|
||||
<div class="wave-dot"></div>
|
||||
<div class="wave-dot"></div>
|
||||
<div class="wave-dot"></div>
|
||||
<div class="wave-dot"></div>
|
||||
</div>
|
||||
<div class="initial-loading-text">搜索中</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-progress-container {
|
||||
position: fixed;
|
||||
top: env(safe-area-inset-top);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 4rem;
|
||||
}
|
||||
|
||||
.search-progress-card {
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.progress-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.progress-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.progress-bar-wrapper {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgb(var(--v-theme-primary)) 0%,
|
||||
rgb(var(--v-theme-primary)) 70%,
|
||||
rgba(var(--v-theme-primary), 0.8) 100%
|
||||
);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-percentage {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 精简标题栏样式 */
|
||||
.search-header {
|
||||
padding: 12px 16px;
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.search-info-container {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-tags {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.search-tag {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* 重新设计的视图切换按钮 */
|
||||
.view-toggle-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.view-toggle-buttons {
|
||||
display: flex;
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.view-toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.view-toggle-btn.active {
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.view-toggle-btn:hover:not(.active) {
|
||||
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||
}
|
||||
|
||||
/* 视图切换加载状态 */
|
||||
.view-changing-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(var(--v-theme-background), 0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.view-changing-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pulse-loader {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pulse-circle {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: rgb(var(--v-theme-primary));
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.pulse-circle:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.pulse-circle:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.view-changing-text {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* 初始的加载状态 */
|
||||
.initial-loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
.initial-loading-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.wave-loader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.wave-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: rgb(var(--v-theme-primary));
|
||||
animation: wave 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.wave-dot:nth-child(1) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.wave-dot:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.wave-dot:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
.wave-dot:nth-child(4) {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-15px);
|
||||
}
|
||||
}
|
||||
|
||||
.initial-loading-text {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.search-results-container {
|
||||
min-height: 50vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.search-header {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.search-title {
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-info-container {
|
||||
flex: 1;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-tags {
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
scrollbar-width: none;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.search-tags::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.view-toggle-container {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.view-toggle-buttons {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.view-toggle-btn {
|
||||
width: 36px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,25 +18,33 @@ function jumpTab(tab: string) {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VTabs v-model="activeTab" show-arrows>
|
||||
<VTab v-if="subType == '电影'" v-for="item in SubscribeMovieTabs" :value="item.tab" @to="jumpTab(item.tab)">
|
||||
<div class="flex align-center min-w-24">
|
||||
<VIcon size="20" start :icon="item.icon" />
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<VTabs v-model="activeTab" show-arrows stacked>
|
||||
<VTab
|
||||
v-if="subType == '电影'"
|
||||
v-for="item in SubscribeMovieTabs"
|
||||
:value="item.tab"
|
||||
@to="jumpTab(item.tab)"
|
||||
class="px-10 rounded-t-lg"
|
||||
>
|
||||
<VIcon size="x-large" start :icon="item.icon" />
|
||||
{{ item.title }}
|
||||
</VTab>
|
||||
<VTab v-if="subType == '电视剧'" v-for="item in SubscribeTvTabs" :value="item.tab" @to="jumpTab(item.tab)">
|
||||
<div class="flex align-center min-w-24">
|
||||
<VIcon size="20" start :icon="item.icon" />
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<VTab
|
||||
v-if="subType == '电视剧'"
|
||||
v-for="item in SubscribeTvTabs"
|
||||
:value="item.tab"
|
||||
@to="jumpTab(item.tab)"
|
||||
class="px-10 rounded-t-lg"
|
||||
>
|
||||
<VIcon size="x-large" start :icon="item.icon" />
|
||||
{{ item.title }}
|
||||
</VTab>
|
||||
</VTabs>
|
||||
|
||||
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
|
||||
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
|
||||
<VWindowItem value="mysub">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<div class="mt-4">
|
||||
<SubscribeListView :type="subType" :subid="subId" />
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
7
src/pages/workflow.vue
Normal file
7
src/pages/workflow.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import WorkflowListView from '@/views/workflow/WorkflowListView.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WorkflowListView />
|
||||
</template>
|
||||
@@ -27,11 +27,25 @@ export default {
|
||||
// set v-btn default color to primary
|
||||
color: 'primary',
|
||||
},
|
||||
VCard: {
|
||||
elevation: 0,
|
||||
rounded: 'lg',
|
||||
},
|
||||
VMenu: {
|
||||
elevation: 0,
|
||||
},
|
||||
VChip: {
|
||||
elevation: 0,
|
||||
},
|
||||
VBottomSheet: {
|
||||
elevation: 0,
|
||||
},
|
||||
VExpansionPanels: {
|
||||
elevation: 0,
|
||||
},
|
||||
VList: {
|
||||
color: 'primary',
|
||||
elevation: 0,
|
||||
},
|
||||
VPagination: {
|
||||
activeColor: 'primary',
|
||||
|
||||
@@ -67,9 +67,9 @@ const theme: VuetifyOptions['theme'] = {
|
||||
'on-primary': '#FFFFFF',
|
||||
'on-success': '#FFFFFF',
|
||||
'on-warning': '#FFFFFF',
|
||||
'background': '#111827',
|
||||
'background': '#0E1116',
|
||||
'on-background': '#E7E3FC',
|
||||
'surface': '#161D2C',
|
||||
'surface': '#14161F',
|
||||
'on-surface': '#E7E3FC',
|
||||
'grey-50': '#2A2E42',
|
||||
'grey-100': '#474360',
|
||||
@@ -87,7 +87,7 @@ const theme: VuetifyOptions['theme'] = {
|
||||
},
|
||||
variables: {
|
||||
'code-color': '#d400ff',
|
||||
'overlay-scrim-background': '#1F2937',
|
||||
'overlay-scrim-background': '#191D21',
|
||||
'overlay-scrim-opacity': 0.6,
|
||||
'hover-opacity': 0.04,
|
||||
'focus-opacity': 0.1,
|
||||
@@ -96,7 +96,7 @@ const theme: VuetifyOptions['theme'] = {
|
||||
'pressed-opacity': 0.14,
|
||||
'dragged-opacity': 0.1,
|
||||
'border-color': '#E7E3FC',
|
||||
'table-header-background': '#1F2937',
|
||||
'table-header-background': '#14161F',
|
||||
'custom-background': '#373452',
|
||||
// Shadows
|
||||
'shadow-key-umbra-opacity': 'rgba(20, 18, 33, 0.08)',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import { configureNProgress } from '@/api/nprogress'
|
||||
import store from '@/store'
|
||||
import { useAuthStore } from '@/stores'
|
||||
|
||||
// Nprogress
|
||||
configureNProgress()
|
||||
@@ -68,6 +68,14 @@ const router = createRouter({
|
||||
subType: '电视剧',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/workflow',
|
||||
component: () => import('../pages/workflow.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/calendar',
|
||||
component: () => import('../pages/calendar.vue'),
|
||||
@@ -88,6 +96,7 @@ const router = createRouter({
|
||||
component: () => import('../pages/history.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
hideFooter: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -170,6 +179,7 @@ const router = createRouter({
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
hideFooter: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -215,9 +225,11 @@ function abortAllControllers() {
|
||||
|
||||
// 路由导航守卫
|
||||
router.beforeEach((to: any, from: any, next: any) => {
|
||||
// 认证 Store
|
||||
const authStore = useAuthStore()
|
||||
// 总是记录非login路由
|
||||
if (to.fullPath != '/login') store.state.auth.originalPath = to.fullPath
|
||||
const isAuthenticated = store.state.auth.token !== null
|
||||
if (to.fullPath != '/login') authStore.originalPath = to.fullPath
|
||||
const isAuthenticated = authStore.token !== null
|
||||
if (to.meta.requiresAuth && !isAuthenticated) {
|
||||
next('/login')
|
||||
} else {
|
||||
|
||||
@@ -49,6 +49,16 @@ export const SystemNavMenus = [
|
||||
admin: false,
|
||||
footer: true,
|
||||
},
|
||||
|
||||
{
|
||||
title: '工作流',
|
||||
full_title: '工作流',
|
||||
icon: 'mdi-state-machine',
|
||||
to: '/workflow',
|
||||
header: '订阅',
|
||||
admin: true,
|
||||
footer: false,
|
||||
},
|
||||
{
|
||||
title: '日历',
|
||||
full_title: '订阅日历',
|
||||
@@ -177,7 +187,7 @@ export const SubscribeMovieTabs = [
|
||||
{
|
||||
title: '我的订阅',
|
||||
tab: 'mysub',
|
||||
icon: 'mdi-heart',
|
||||
icon: 'mdi-bell-check',
|
||||
},
|
||||
{
|
||||
title: '热门订阅',
|
||||
@@ -191,7 +201,7 @@ export const SubscribeTvTabs = [
|
||||
{
|
||||
title: '我的订阅',
|
||||
tab: 'mysub',
|
||||
icon: 'mdi-heart',
|
||||
icon: 'mdi-bell-check',
|
||||
},
|
||||
{
|
||||
title: '热门订阅',
|
||||
@@ -215,24 +225,24 @@ export const PluginTabs = [
|
||||
{
|
||||
title: '插件市场',
|
||||
tab: 'market',
|
||||
icon: 'mdi-store',
|
||||
icon: 'mdi-shopping',
|
||||
},
|
||||
]
|
||||
|
||||
// 发现标签页
|
||||
export const DiscoverTabs = [
|
||||
{
|
||||
title: 'TheMovieDb',
|
||||
name: 'TheMovieDb',
|
||||
tab: 'themoviedb',
|
||||
icon: 'themoviedb',
|
||||
},
|
||||
{
|
||||
title: '豆瓣',
|
||||
name: '豆瓣',
|
||||
tab: 'douban',
|
||||
icon: 'douban',
|
||||
},
|
||||
{
|
||||
title: 'Bangumi',
|
||||
name: 'Bangumi',
|
||||
tab: 'bangumi',
|
||||
icon: 'bangumi',
|
||||
},
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import type { Module } from 'vuex'
|
||||
|
||||
// 定义状态类型
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
remember: boolean
|
||||
superUser: boolean
|
||||
userID: number
|
||||
userName: string
|
||||
avatar: string
|
||||
originalPath: string | null
|
||||
level: number
|
||||
permissions: { [key: string]: any }
|
||||
}
|
||||
|
||||
// 定义根状态类型
|
||||
interface RootState {
|
||||
auth: AuthState
|
||||
}
|
||||
|
||||
// 用户信息模块
|
||||
const authModule: Module<AuthState, RootState> = {
|
||||
namespaced: true,
|
||||
state: {
|
||||
token: null, // 用户令牌
|
||||
remember: false, // 记住我
|
||||
superUser: false, // 超级管理员
|
||||
userID: 999, // 用户ID
|
||||
userName: '', // 用户名
|
||||
avatar: '', // 头像
|
||||
originalPath: null, // 原始路径
|
||||
level: 1, // 用户认证等级 1-未认证 2-已认证
|
||||
permissions: {},
|
||||
},
|
||||
mutations: {
|
||||
setToken(state, token: string) {
|
||||
state.token = token
|
||||
},
|
||||
clearToken(state) {
|
||||
state.token = null
|
||||
},
|
||||
setRemember(state, remember: boolean) {
|
||||
state.remember = remember
|
||||
},
|
||||
setSuperUser(state, superUser: boolean) {
|
||||
state.superUser = superUser
|
||||
},
|
||||
setUserID(state, userID: number) {
|
||||
state.userID = userID
|
||||
},
|
||||
setUserName(state, userName: string) {
|
||||
state.userName = userName
|
||||
},
|
||||
setAvatar(state, avatar: string) {
|
||||
state.avatar = avatar
|
||||
},
|
||||
setOriginalPath(state, originalPath: string) {
|
||||
state.originalPath = originalPath
|
||||
},
|
||||
setLevel(state, level: number) {
|
||||
state.level = level
|
||||
},
|
||||
setPermissions(state, permissions: object) {
|
||||
state.permissions = permissions
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
login({ commit }, { token, remember, superUser, userID, userName, avatar, level, permissions }) {
|
||||
commit('setToken', token)
|
||||
commit('setRemember', remember)
|
||||
commit('setSuperUser', superUser)
|
||||
commit('setUserID', userID)
|
||||
commit('setUserName', userName)
|
||||
commit('setAvatar', avatar)
|
||||
commit('setLevel', level)
|
||||
commit('setPermissions', permissions)
|
||||
},
|
||||
logout({ commit }) {
|
||||
commit('clearToken')
|
||||
commit('setOriginalPath', null)
|
||||
},
|
||||
},
|
||||
getters: {
|
||||
getToken: state => state.token,
|
||||
getRemember: state => state.remember,
|
||||
getSuperUser: state => state.superUser,
|
||||
getUserID: state => state.userID,
|
||||
getUserName: state => state.userName,
|
||||
getAvatar: state => state.avatar,
|
||||
getOriginalPath: state => state.originalPath,
|
||||
getLevel: state => state.level,
|
||||
getPermissions: state => state.permissions,
|
||||
},
|
||||
}
|
||||
|
||||
export default authModule
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createStore } from 'vuex'
|
||||
import createPersistedState from 'vuex-persistedstate'
|
||||
import authModule from './auth'
|
||||
|
||||
const store = createStore({
|
||||
modules: {
|
||||
// 用户认证store
|
||||
auth: authModule,
|
||||
},
|
||||
plugins: [
|
||||
createPersistedState({
|
||||
// 配置持久化存储的选项
|
||||
storage: window.localStorage, // 使用 localStorage 存储状态
|
||||
key: 'moviepilot', // 存储的键名
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
export default store
|
||||
42
src/stores/auth.ts
Normal file
42
src/stores/auth.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { authState } from '@/stores/types'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: (): authState => ({
|
||||
token: null,
|
||||
remember: false,
|
||||
originalPath: null,
|
||||
}),
|
||||
|
||||
// 全局持久化
|
||||
persist: true,
|
||||
|
||||
actions: {
|
||||
setToken(token: string | null) {
|
||||
this.token = token
|
||||
},
|
||||
clearToken() {
|
||||
this.token = null
|
||||
},
|
||||
setRemember(remember: boolean) {
|
||||
this.remember = remember
|
||||
},
|
||||
setOriginalPath(originalPath: string | null) {
|
||||
this.originalPath = originalPath
|
||||
},
|
||||
login(payload: authState) {
|
||||
this.setToken(payload.token)
|
||||
this.setRemember(payload.remember)
|
||||
},
|
||||
logout() {
|
||||
this.clearToken()
|
||||
this.setOriginalPath(null)
|
||||
},
|
||||
},
|
||||
|
||||
getters: {
|
||||
getToken: state => state.token,
|
||||
getRemember: state => state.remember,
|
||||
getOriginalPath: state => state.originalPath,
|
||||
},
|
||||
})
|
||||
16
src/stores/index.ts
Normal file
16
src/stores/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createPinia } from 'pinia'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
|
||||
// 创建 Pinia 实例
|
||||
const pinia = createPinia()
|
||||
|
||||
// 使用持久化插件
|
||||
pinia.use(piniaPluginPersistedstate)
|
||||
|
||||
export default pinia
|
||||
|
||||
// 所有的 store
|
||||
import { useAuthStore } from './auth'
|
||||
import { useUserStore } from './user'
|
||||
|
||||
export { useAuthStore, useUserStore }
|
||||
23
src/stores/types.ts
Normal file
23
src/stores/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface authState {
|
||||
// 用户令牌
|
||||
token: string | null
|
||||
// 记住我
|
||||
remember: boolean
|
||||
// 原始路径
|
||||
originalPath?: string | null
|
||||
}
|
||||
|
||||
export interface userState {
|
||||
// 是否属于超级管理员
|
||||
superUser: boolean
|
||||
// 用户ID
|
||||
userID: number
|
||||
// 用户名
|
||||
userName: string
|
||||
// 头像
|
||||
avatar: string
|
||||
// 用户认证等级 1-未认证 2-已认证
|
||||
level: number
|
||||
// 权限
|
||||
permissions: { [key: string]: any }
|
||||
}
|
||||
62
src/stores/user.ts
Normal file
62
src/stores/user.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { userState } from '@/stores/types'
|
||||
|
||||
export const useUserStore = defineStore('user', {
|
||||
state: (): userState => ({
|
||||
superUser: false,
|
||||
userID: -1,
|
||||
userName: '',
|
||||
avatar: '',
|
||||
level: 1,
|
||||
permissions: {},
|
||||
}),
|
||||
|
||||
// 全局持久化
|
||||
persist: true,
|
||||
|
||||
actions: {
|
||||
setSuperUser(superUser: boolean) {
|
||||
this.superUser = superUser
|
||||
},
|
||||
setUserID(userID: number) {
|
||||
this.userID = userID
|
||||
},
|
||||
setUserName(userName: string) {
|
||||
this.userName = userName
|
||||
},
|
||||
setAvatar(avatar: string) {
|
||||
this.avatar = avatar
|
||||
},
|
||||
setLevel(level: number) {
|
||||
this.level = level
|
||||
},
|
||||
setPermissions(permissions: object) {
|
||||
this.permissions = permissions
|
||||
},
|
||||
loginUser(payload: userState) {
|
||||
this.setSuperUser(payload.superUser)
|
||||
this.setUserID(payload.userID)
|
||||
this.setUserName(payload.userName)
|
||||
this.setAvatar(payload.avatar)
|
||||
this.setLevel(payload.level)
|
||||
this.setPermissions(payload.permissions)
|
||||
},
|
||||
reset() {
|
||||
this.setSuperUser(false)
|
||||
this.setUserID(-1)
|
||||
this.setUserName('')
|
||||
this.setAvatar('')
|
||||
this.setLevel(1)
|
||||
this.setPermissions({})
|
||||
},
|
||||
},
|
||||
|
||||
getters: {
|
||||
getSuperUser: state => state.superUser,
|
||||
getUserID: state => state.userID,
|
||||
getUserName: state => state.userName,
|
||||
getAvatar: state => state.avatar,
|
||||
getLevel: state => state.level,
|
||||
getPermissions: state => state.permissions,
|
||||
},
|
||||
})
|
||||
@@ -3,27 +3,31 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
|
||||
html.v-overlay-scroll-blocked {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
html.v-overlay-scroll-blocked body {
|
||||
--v-body-scroll-y: 0px !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px){
|
||||
@media (width <= 768px){
|
||||
html.v-overlay-scroll-blocked {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin hide-scrollbar {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (width <= 768px) {
|
||||
html,body {
|
||||
@include hide-scrollbar;
|
||||
}
|
||||
@@ -35,9 +39,9 @@ html.v-overlay-scroll-blocked {
|
||||
}
|
||||
|
||||
#nprogress .peg {
|
||||
width: 5px;
|
||||
box-shadow: 0 0 10px rgb(var(--v-theme-primary)), 0 0 5px rgb(var(--v-theme-primary)) !important;
|
||||
transform: rotate(0deg) translate(0, 0px);
|
||||
inline-size: 5px;
|
||||
transform: rotate(0deg) translate(0, 0);
|
||||
}
|
||||
|
||||
.v-toast--bottom {
|
||||
@@ -218,6 +222,31 @@ html.v-overlay-scroll-blocked {
|
||||
padding-block-end: 1rem;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.user-list-container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.grid-user-card {
|
||||
gap: 1rem;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 601px) and (width <= 960px) {
|
||||
.grid-user-card{
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 961px) {
|
||||
.grid-user-card {
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.grid-app-card {
|
||||
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||
padding-block-end: 1rem;
|
||||
|
||||
@@ -43,6 +43,10 @@ async function loadMediaStatistic() {
|
||||
onMounted(() => {
|
||||
loadMediaStatistic()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
loadMediaStatistic()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -36,6 +36,10 @@ async function getStorage() {
|
||||
onMounted(() => {
|
||||
getStorage()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
getStorage()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -66,7 +70,7 @@ onMounted(() => {
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
<style lang="scss" scoped>
|
||||
@use '@layouts/styles/mixins' as layoutsMixins;
|
||||
|
||||
.v-card .triangle-bg {
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
import { useTheme } from 'vuetify'
|
||||
import api from '@/api'
|
||||
import { hexToRgb } from '@layouts/utils'
|
||||
import { useUserStore } from '@/stores'
|
||||
|
||||
const vuetifyTheme = useTheme()
|
||||
|
||||
// 从Vuex Store中获取信息
|
||||
const store = useStore()
|
||||
const superUser = store.state.auth.superUser
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
const superUser = userStore.superUser
|
||||
|
||||
const options = controlledComputed(
|
||||
() => vuetifyTheme.name.value,
|
||||
@@ -112,6 +113,10 @@ async function getWeeklyData() {
|
||||
onMounted(() => {
|
||||
getWeeklyData()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
getWeeklyData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user