mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-09 07:42:40 +08:00
Compare commits
136 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc36496aee | ||
|
|
1c8881d7a4 | ||
|
|
f6e8aacd0f | ||
|
|
79ddc39492 | ||
|
|
e63c5fb8e5 | ||
|
|
695f4827fd | ||
|
|
5a8b183c0f | ||
|
|
2845a889ed | ||
|
|
6333103050 | ||
|
|
cb6be91538 | ||
|
|
8cdd4b4af5 | ||
|
|
f4ec2029d9 | ||
|
|
b84b0f229f | ||
|
|
ef6a01a32f | ||
|
|
b451b8066a | ||
|
|
57efd516c5 | ||
|
|
d5979e6bf3 | ||
|
|
d75970cb2a | ||
|
|
ad4bb07cd7 | ||
|
|
9c558c3625 | ||
|
|
b467bb6c56 | ||
|
|
5cd021ea85 | ||
|
|
3d64382c9b | ||
|
|
6d5d4354d9 | ||
|
|
1b43446b5c | ||
|
|
7a9984f392 | ||
|
|
3c6fbfb106 | ||
|
|
bab46964ff | ||
|
|
661919f27a | ||
|
|
f3a03349b4 | ||
|
|
29791bf986 | ||
|
|
a06f0f29c6 | ||
|
|
b426d94180 | ||
|
|
5618d87e58 | ||
|
|
721d4f7685 | ||
|
|
7a025bcd38 | ||
|
|
24a8125621 | ||
|
|
468584a906 | ||
|
|
c056ec9377 | ||
|
|
87239994ae | ||
|
|
da09860a53 | ||
|
|
195ee5b2a6 | ||
|
|
32621ee299 | ||
|
|
40645180a0 | ||
|
|
59d4b1e544 | ||
|
|
8962a2c4ac | ||
|
|
6955f35ad1 | ||
|
|
1f722e7d7f | ||
|
|
5e587dfd88 | ||
|
|
2c687e5648 | ||
|
|
fdb0f63283 | ||
|
|
002e675b47 | ||
|
|
114f2a2dd0 | ||
|
|
c314d49e11 | ||
|
|
f5d0556808 | ||
|
|
27bc2a488f | ||
|
|
3a5999c341 | ||
|
|
a80a099ee7 | ||
|
|
68f458738a | ||
|
|
0f08f69738 | ||
|
|
a664066465 | ||
|
|
e6c11665a5 | ||
|
|
c119384c22 | ||
|
|
787cccb89f | ||
|
|
3df5d75c46 | ||
|
|
de6ad2479e | ||
|
|
632dfbaf10 | ||
|
|
68c14c24b8 | ||
|
|
d343d6d54d | ||
|
|
391a160f97 | ||
|
|
2d95110f75 | ||
|
|
e2ced8d36d | ||
|
|
a2b4511602 | ||
|
|
bdccc71b64 | ||
|
|
d7038a7d18 | ||
|
|
3998e1f685 | ||
|
|
5def9d5f81 | ||
|
|
c62937371e | ||
|
|
52843dcf97 | ||
|
|
ef5680d5ad | ||
|
|
bd3f24c84b | ||
|
|
399f85c52e | ||
|
|
14430e5c89 | ||
|
|
b703757d28 | ||
|
|
b642eabbb3 | ||
|
|
673596d8f9 | ||
|
|
b14e927e6c | ||
|
|
b03ae41ac7 | ||
|
|
92a0a9fe2f | ||
|
|
2511acfea1 | ||
|
|
361a4e0414 | ||
|
|
7e310236fe | ||
|
|
8705606c70 | ||
|
|
1f812a5258 | ||
|
|
e9264fa472 | ||
|
|
9164a1aefc | ||
|
|
30351a02ee | ||
|
|
7f918408a6 | ||
|
|
82f69bcad0 | ||
|
|
83b25eabbb | ||
|
|
47da6db51a | ||
|
|
eee092a7fd | ||
|
|
4c0f65fcbc | ||
|
|
acbd979569 | ||
|
|
52b68c18bf | ||
|
|
c6a74a75da | ||
|
|
e39eb62f52 | ||
|
|
4ecec4865d | ||
|
|
589007a22a | ||
|
|
4d4c9516c6 | ||
|
|
8491f26617 | ||
|
|
fcb3768a76 | ||
|
|
966bb769df | ||
|
|
dc8f7caab0 | ||
|
|
683346d652 | ||
|
|
f5fe39b2d2 | ||
|
|
51beb53f51 | ||
|
|
9d3f03c83a | ||
|
|
3eda1e4ef7 | ||
|
|
7181f83d66 | ||
|
|
fffad6e1b8 | ||
|
|
7f3906e5cb | ||
|
|
f836d175f0 | ||
|
|
f49cafc0cc | ||
|
|
a3ecad3436 | ||
|
|
a019dbd44e | ||
|
|
b316f960a1 | ||
|
|
d049b26825 | ||
|
|
852579c6ee | ||
|
|
5adcfa1877 | ||
|
|
f74458629e | ||
|
|
798f9249f8 | ||
|
|
6b4383643f | ||
|
|
256e8d0452 | ||
|
|
4112214c1f | ||
|
|
c183158ffe |
40
auto-imports.d.ts
vendored
40
auto-imports.d.ts
vendored
@@ -7,6 +7,7 @@
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
||||
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
|
||||
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
|
||||
const computed: typeof import('vue')['computed']
|
||||
@@ -21,13 +22,11 @@ declare global {
|
||||
const createGenericProjection: typeof import('@vueuse/math')['createGenericProjection']
|
||||
const createGlobalState: typeof import('@vueuse/core')['createGlobalState']
|
||||
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
|
||||
const createLogger: typeof import('vuex')['createLogger']
|
||||
const createNamespacedHelpers: typeof import('vuex')['createNamespacedHelpers']
|
||||
const createPinia: typeof import('pinia')['createPinia']
|
||||
const createProjection: typeof import('@vueuse/math')['createProjection']
|
||||
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
|
||||
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
|
||||
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
|
||||
const createStore: typeof import('vuex')['createStore']
|
||||
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
|
||||
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
|
||||
const customRef: typeof import('vue')['customRef']
|
||||
@@ -35,9 +34,11 @@ declare global {
|
||||
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
|
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const defineStore: typeof import('pinia')['defineStore']
|
||||
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const extendRef: typeof import('@vueuse/core')['extendRef']
|
||||
const getActivePinia: typeof import('pinia')['getActivePinia']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||
const h: typeof import('vue')['h']
|
||||
@@ -53,10 +54,11 @@ declare global {
|
||||
const logicNot: typeof import('@vueuse/math')['logicNot']
|
||||
const logicOr: typeof import('@vueuse/math')['logicOr']
|
||||
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
|
||||
const mapActions: typeof import('vuex')['mapActions']
|
||||
const mapGetters: typeof import('vuex')['mapGetters']
|
||||
const mapMutations: typeof import('vuex')['mapMutations']
|
||||
const mapState: typeof import('vuex')['mapState']
|
||||
const mapActions: typeof import('pinia')['mapActions']
|
||||
const mapGetters: typeof import('pinia')['mapGetters']
|
||||
const mapState: typeof import('pinia')['mapState']
|
||||
const mapStores: typeof import('pinia')['mapStores']
|
||||
const mapWritableState: typeof import('pinia')['mapWritableState']
|
||||
const markRaw: typeof import('vue')['markRaw']
|
||||
const nextTick: typeof import('vue')['nextTick']
|
||||
const onActivated: typeof import('vue')['onActivated']
|
||||
@@ -99,9 +101,12 @@ declare global {
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const resolveRef: typeof import('@vueuse/core')['resolveRef']
|
||||
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
|
||||
const setActivePinia: typeof import('pinia')['setActivePinia']
|
||||
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const storeToRefs: typeof import('pinia')['storeToRefs']
|
||||
const syncRef: typeof import('@vueuse/core')['syncRef']
|
||||
const syncRefs: typeof import('@vueuse/core')['syncRefs']
|
||||
const templateRef: typeof import('@vueuse/core')['templateRef']
|
||||
@@ -263,7 +268,6 @@ declare global {
|
||||
const useStepper: typeof import('@vueuse/core')['useStepper']
|
||||
const useStorage: typeof import('@vueuse/core')['useStorage']
|
||||
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
|
||||
const useStore: typeof import('vuex')['useStore']
|
||||
const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
|
||||
const useSum: typeof import('@vueuse/math')['useSum']
|
||||
const useSupported: typeof import('@vueuse/core')['useSupported']
|
||||
@@ -331,6 +335,7 @@ declare module 'vue' {
|
||||
interface GlobalComponents {}
|
||||
interface ComponentCustomProperties {
|
||||
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
|
||||
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
|
||||
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
|
||||
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
|
||||
readonly computed: UnwrapRef<typeof import('vue')['computed']>
|
||||
@@ -345,13 +350,11 @@ declare module 'vue' {
|
||||
readonly createGenericProjection: UnwrapRef<typeof import('@vueuse/math')['createGenericProjection']>
|
||||
readonly createGlobalState: UnwrapRef<typeof import('@vueuse/core')['createGlobalState']>
|
||||
readonly createInjectionState: UnwrapRef<typeof import('@vueuse/core')['createInjectionState']>
|
||||
readonly createLogger: UnwrapRef<typeof import('vuex')['createLogger']>
|
||||
readonly createNamespacedHelpers: UnwrapRef<typeof import('vuex')['createNamespacedHelpers']>
|
||||
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
|
||||
readonly createProjection: UnwrapRef<typeof import('@vueuse/math')['createProjection']>
|
||||
readonly createReactiveFn: UnwrapRef<typeof import('@vueuse/core')['createReactiveFn']>
|
||||
readonly createReusableTemplate: UnwrapRef<typeof import('@vueuse/core')['createReusableTemplate']>
|
||||
readonly createSharedComposable: UnwrapRef<typeof import('@vueuse/core')['createSharedComposable']>
|
||||
readonly createStore: UnwrapRef<typeof import('vuex')['createStore']>
|
||||
readonly createTemplatePromise: UnwrapRef<typeof import('@vueuse/core')['createTemplatePromise']>
|
||||
readonly createUnrefFn: UnwrapRef<typeof import('@vueuse/core')['createUnrefFn']>
|
||||
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
|
||||
@@ -359,9 +362,11 @@ declare module 'vue' {
|
||||
readonly debouncedWatch: UnwrapRef<typeof import('@vueuse/core')['debouncedWatch']>
|
||||
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
|
||||
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
|
||||
readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
|
||||
readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']>
|
||||
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
|
||||
readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
|
||||
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
|
||||
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
|
||||
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
||||
readonly h: UnwrapRef<typeof import('vue')['h']>
|
||||
@@ -377,10 +382,11 @@ declare module 'vue' {
|
||||
readonly logicNot: UnwrapRef<typeof import('@vueuse/math')['logicNot']>
|
||||
readonly logicOr: UnwrapRef<typeof import('@vueuse/math')['logicOr']>
|
||||
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
|
||||
readonly mapActions: UnwrapRef<typeof import('vuex')['mapActions']>
|
||||
readonly mapGetters: UnwrapRef<typeof import('vuex')['mapGetters']>
|
||||
readonly mapMutations: UnwrapRef<typeof import('vuex')['mapMutations']>
|
||||
readonly mapState: UnwrapRef<typeof import('vuex')['mapState']>
|
||||
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
|
||||
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
|
||||
readonly mapState: UnwrapRef<typeof import('pinia')['mapState']>
|
||||
readonly mapStores: UnwrapRef<typeof import('pinia')['mapStores']>
|
||||
readonly mapWritableState: UnwrapRef<typeof import('pinia')['mapWritableState']>
|
||||
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
|
||||
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
|
||||
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
|
||||
@@ -423,9 +429,12 @@ declare module 'vue' {
|
||||
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
|
||||
readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']>
|
||||
readonly resolveUnref: UnwrapRef<typeof import('@vueuse/core')['resolveUnref']>
|
||||
readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>
|
||||
readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']>
|
||||
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
|
||||
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
|
||||
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
|
||||
readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']>
|
||||
readonly syncRef: UnwrapRef<typeof import('@vueuse/core')['syncRef']>
|
||||
readonly syncRefs: UnwrapRef<typeof import('@vueuse/core')['syncRefs']>
|
||||
readonly templateRef: UnwrapRef<typeof import('@vueuse/core')['templateRef']>
|
||||
@@ -587,7 +596,6 @@ declare module 'vue' {
|
||||
readonly useStepper: UnwrapRef<typeof import('@vueuse/core')['useStepper']>
|
||||
readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']>
|
||||
readonly useStorageAsync: UnwrapRef<typeof import('@vueuse/core')['useStorageAsync']>
|
||||
readonly useStore: UnwrapRef<typeof import('vuex')['useStore']>
|
||||
readonly useStyleTag: UnwrapRef<typeof import('@vueuse/core')['useStyleTag']>
|
||||
readonly useSum: UnwrapRef<typeof import('@vueuse/math')['useSum']>
|
||||
readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']>
|
||||
|
||||
25
index.html
25
index.html
@@ -1,5 +1,6 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en"
|
||||
style="overflow: hidden auto; min-block-size: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));background: var(--initial-loader-bg, #fff);">
|
||||
|
||||
<head>
|
||||
<meta http-equiv="pragma" content="no-cache">
|
||||
@@ -29,9 +30,17 @@
|
||||
<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>
|
||||
<body style="margin: 0;">
|
||||
<div id="loading-bg">
|
||||
<div class="loading-logo">
|
||||
<!-- Logo -->
|
||||
@@ -146,16 +155,6 @@
|
||||
</div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<script>
|
||||
const loaderColor = localStorage.getItem('materio-initial-loader-bg') || '#FFFFFF'
|
||||
const primaryColor = localStorage.getItem('materio-initial-loader-color') || '#9155FD'
|
||||
|
||||
if (loaderColor)
|
||||
document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
|
||||
|
||||
if (primaryColor)
|
||||
document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.2.2",
|
||||
"version": "2.3.3",
|
||||
"private": true,
|
||||
"bin": "dist/service.js",
|
||||
"scripts": {
|
||||
@@ -26,6 +26,12 @@
|
||||
"@fullcalendar/timegrid": "^6.1.15",
|
||||
"@fullcalendar/vue3": "^6.1.15",
|
||||
"@iconify/utils": "^2.2.1",
|
||||
"@vue-flow/background": "^1.3.2",
|
||||
"@vue-flow/controls": "^1.1.2",
|
||||
"@vue-flow/core": "^1.42.1",
|
||||
"@vue-flow/minimap": "^1.5.2",
|
||||
"@vue-flow/node-resizer": "^1.4.0",
|
||||
"@vue-flow/node-toolbar": "^1.1.0",
|
||||
"@vue-js-cron/vuetify": "^5.0.9",
|
||||
"@vueuse/core": "^12.4.0",
|
||||
"@vueuse/math": "^12.4.0",
|
||||
@@ -37,9 +43,11 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"express": "^4.21.2",
|
||||
"express-http-proxy": "^2.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"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 +60,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 +68,7 @@
|
||||
"@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/node": "^20.1.4",
|
||||
"@types/webfontloader": "^1.6.34",
|
||||
"@typescript-eslint/eslint-plugin": "^8.20.0",
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow: hidden auto;
|
||||
background: var(--initial-loader-bg, #fff);
|
||||
min-block-size: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
#loading-bg {
|
||||
position: absolute;
|
||||
z-index: 999;
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
display: block;
|
||||
background: var(--initial-loader-bg, #fff);
|
||||
block-size: 100vh;
|
||||
@@ -82,4 +72,4 @@ html {
|
||||
opacity: 1;
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { ThemeSwitcherTheme } from '@layouts/types'
|
||||
import api from '@/api'
|
||||
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { saveLocalTheme } from '../utils/theme'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -102,8 +103,7 @@ function updateTheme() {
|
||||
savedTheme.value = theme
|
||||
themeTransition()
|
||||
// 保存主题到本地
|
||||
localStorage.setItem('theme', theme)
|
||||
localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background)
|
||||
saveLocalTheme(theme, globalTheme)
|
||||
}
|
||||
|
||||
// 切换主题
|
||||
@@ -209,7 +209,7 @@ onMounted(() => {
|
||||
</VList>
|
||||
</VMenu>
|
||||
<!-- 自定义 CSS -- -->
|
||||
<VDialog v-model="cssDialog" persistent max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VDialog v-if="cssDialog" v-model="cssDialog" persistent max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard title="自定义主题风格">
|
||||
<DialogCloseBtn @click="cssDialog = false" />
|
||||
<VDivider />
|
||||
|
||||
@@ -212,10 +212,6 @@ h6,
|
||||
}
|
||||
}
|
||||
|
||||
.v-data-table-footer {
|
||||
margin-block-start: 1rem;
|
||||
}
|
||||
|
||||
// 👉 v-field
|
||||
.v-field:hover .v-field__outline {
|
||||
--v-field-border-opacity: var(--v-medium-emphasis-opacity);
|
||||
|
||||
@@ -176,10 +176,6 @@
|
||||
th {
|
||||
background: rgb(var(--v-table-header-background)) !important;
|
||||
}
|
||||
|
||||
.v-data-table-footer {
|
||||
margin-block-start: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Pagination
|
||||
|
||||
@@ -10,8 +10,7 @@ export function useDefer(maxFrameCount = 1) {
|
||||
const refreshFrameCount = () => {
|
||||
requestAnimationFrame(() => {
|
||||
frameCount.value++
|
||||
if (frameCount.value < maxFrameCount)
|
||||
refreshFrameCount()
|
||||
if (frameCount.value < maxFrameCount) refreshFrameCount()
|
||||
})
|
||||
}
|
||||
refreshFrameCount()
|
||||
@@ -19,3 +18,9 @@ export function useDefer(maxFrameCount = 1) {
|
||||
return frameCount.value >= showInFrameCount
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureRenderComplete(callback: () => void) {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(callback)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -153,3 +153,12 @@ export function formatDateDifference(dateString: string): string {
|
||||
if (!dateString) return ''
|
||||
return dayjs(dateString).fromNow()
|
||||
}
|
||||
|
||||
// 格式化评份,如为10及以下的数按原值显示,否则格式化为xxM、xxK显示
|
||||
export function formatRating(rating: number): string {
|
||||
if (!rating) return ''
|
||||
if (rating <= 10) return rating.toString()
|
||||
if (rating < 1000) return rating.toLocaleString()
|
||||
if (rating < 1000 * 1000) return `${(rating / 1000).toFixed(1)}K`
|
||||
return `${(rating / 1000 / 1000).toFixed(1)}M`
|
||||
}
|
||||
|
||||
6
src/@core/utils/theme.ts
Normal file
6
src/@core/utils/theme.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function saveLocalTheme(name: string, theme: any) {
|
||||
// 存储主题到本地
|
||||
localStorage.setItem('theme', name)
|
||||
localStorage.setItem('materio-initial-loader-bg', theme.current.value.colors.background)
|
||||
localStorage.setItem('materio-initial-loader-color', theme.current.value.colors.primary)
|
||||
}
|
||||
121
src/@core/utils/workflow.ts
Normal file
121
src/@core/utils/workflow.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useVueFlow } from '@vue-flow/core'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
/**
|
||||
* @returns {string} - A unique id.
|
||||
*/
|
||||
function getId() {
|
||||
// 生成以act_开头的唯一id
|
||||
return 'act_' + Math.random().toString(36).substr(2, 9)
|
||||
}
|
||||
|
||||
/**
|
||||
* In a real world scenario you'd want to avoid creating refs in a global scope like this as they might not be cleaned up properly.
|
||||
* @type {{draggedData: Ref<any>, isDragOver: Ref<boolean>, isDragging: Ref<boolean>}}
|
||||
*/
|
||||
const state = {
|
||||
/**
|
||||
* The type of the node being dragged.
|
||||
*/
|
||||
draggedData: ref<any | null>({}),
|
||||
isDragOver: ref(false),
|
||||
isDragging: ref(false),
|
||||
}
|
||||
|
||||
export default function useDragAndDrop() {
|
||||
const { draggedData, isDragOver, isDragging } = state
|
||||
|
||||
const { addNodes, screenToFlowCoordinate, onNodesInitialized, updateNode } = useVueFlow()
|
||||
|
||||
watch(isDragging, dragging => {
|
||||
document.body.style.userSelect = dragging ? 'none' : ''
|
||||
})
|
||||
|
||||
function onDragStart(event: any, data: any) {
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.setData('application/vueflow', data)
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
draggedData.value = data
|
||||
isDragging.value = true
|
||||
|
||||
document.addEventListener('drop', onDragEnd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the drag over event.
|
||||
*
|
||||
* @param {DragEvent} event
|
||||
*/
|
||||
function onDragOver(event: any) {
|
||||
event.preventDefault()
|
||||
|
||||
if (draggedData.value) {
|
||||
isDragOver.value = true
|
||||
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
isDragging.value = false
|
||||
isDragOver.value = false
|
||||
draggedData.value = null
|
||||
document.removeEventListener('drop', onDragEnd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the drop event.
|
||||
*
|
||||
* @param {DragEvent} event
|
||||
*/
|
||||
function onDrop(event: any) {
|
||||
const position = screenToFlowCoordinate({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
})
|
||||
|
||||
const nodeId = getId()
|
||||
|
||||
const newNode = {
|
||||
id: nodeId,
|
||||
type: draggedData.value?.type,
|
||||
name: draggedData.value?.name,
|
||||
description: draggedData.value?.description,
|
||||
position,
|
||||
data: draggedData.value?.data,
|
||||
}
|
||||
|
||||
/**
|
||||
* Align node position after drop, so it's centered to the mouse
|
||||
*
|
||||
* We can hook into events even in a callback, and we can remove the event listener after it's been called.
|
||||
*/
|
||||
const { off } = onNodesInitialized(() => {
|
||||
updateNode(nodeId, node => ({
|
||||
position: { x: node.position.x - node.dimensions.width / 2, y: node.position.y - node.dimensions.height / 2 },
|
||||
}))
|
||||
|
||||
off()
|
||||
})
|
||||
|
||||
addNodes(newNode)
|
||||
}
|
||||
|
||||
return {
|
||||
draggedData,
|
||||
isDragOver,
|
||||
isDragging,
|
||||
onDragStart,
|
||||
onDragLeave,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ $layout-horizontal-nav-layout-navbar-z-index: 11 !default;
|
||||
$layout-boxed-content-width: 90rem !default;
|
||||
|
||||
// 👉Footer
|
||||
$layout-vertical-nav-footer-height: 0rem !default;
|
||||
$layout-vertical-nav-footer-height: 3.5rem !default;
|
||||
|
||||
// 👉 Layout overlay
|
||||
$layout-overlay-z-index: 11 !default;
|
||||
|
||||
33
src/App.vue
33
src/App.vue
@@ -1,15 +1,16 @@
|
||||
<script lang="ts" setup>
|
||||
import { useTheme } from 'vuetify'
|
||||
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
|
||||
|
||||
const { global: globalTheme } = useTheme()
|
||||
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
|
||||
|
||||
// 生效主题
|
||||
async function setTheme() {
|
||||
let themeValue = localStorage.getItem('theme') || 'light'
|
||||
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
|
||||
}
|
||||
const { global: globalTheme } = useTheme()
|
||||
let themeValue = localStorage.getItem('theme') || 'light'
|
||||
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
|
||||
|
||||
// 显示状态
|
||||
const show = ref(false)
|
||||
|
||||
// ApexCharts 全局配置
|
||||
declare global {
|
||||
@@ -41,14 +42,24 @@ if (window.Apex) {
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时,加载当前用户数据
|
||||
onBeforeMount(async () => {
|
||||
setTheme()
|
||||
onMounted(() => {
|
||||
ensureRenderComplete(() => {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
// 移除加载动画
|
||||
removeEl('#loading-bg')
|
||||
// 将background属性从html的style中移除
|
||||
document.documentElement.style.removeProperty('background')
|
||||
// 显示页面
|
||||
show.value = true
|
||||
}, 1500)
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VApp>
|
||||
<VApp v-show="show">
|
||||
<RouterView />
|
||||
</VApp>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from 'axios'
|
||||
import router from '@/router'
|
||||
import store from '@/store'
|
||||
import { useAuthStore } from '@/stores'
|
||||
|
||||
// 创建axios实例
|
||||
const api = axios.create({
|
||||
@@ -9,10 +9,12 @@ const api = axios.create({
|
||||
|
||||
// 添加请求拦截器
|
||||
api.interceptors.request.use(config => {
|
||||
// 认证 Store
|
||||
const authStore = useAuthStore()
|
||||
// 在请求头中添加token
|
||||
const token = store.state.auth.token
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
|
||||
if (authStore.token) {
|
||||
config.headers.Authorization = `Bearer ${authStore.token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
@@ -26,8 +28,10 @@ api.interceptors.response.use(
|
||||
// 请求超时
|
||||
return Promise.reject(new Error(error))
|
||||
} else if (error.response.status === 403) {
|
||||
// 认证 Store
|
||||
const authStore = useAuthStore()
|
||||
// 清除登录状态信息
|
||||
store.dispatch('auth/logout')
|
||||
authStore.logout()
|
||||
// token验证失败,跳转到登录页面
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ export interface Subscribe {
|
||||
tmdbid: number
|
||||
// 豆瓣ID
|
||||
doubanid?: string
|
||||
// Bangumi ID
|
||||
bangumiid?: string
|
||||
// 其它媒体ID
|
||||
mediaid?: string
|
||||
// 季号
|
||||
season?: number
|
||||
// 海报
|
||||
@@ -208,6 +212,10 @@ export interface MediaInfo {
|
||||
bangumi_id?: string
|
||||
// 合集ID
|
||||
collection_id?: number
|
||||
// 其它媒体ID前缀
|
||||
mediaid_prefix?: string
|
||||
// 其它媒体ID值
|
||||
media_id?: string
|
||||
// 媒体原语种
|
||||
original_language?: string
|
||||
// 媒体原发行标题
|
||||
@@ -280,6 +288,24 @@ export interface MediaInfo {
|
||||
names?: string[]
|
||||
}
|
||||
|
||||
// 季信息
|
||||
export interface MediaSeason {
|
||||
// 上映日期
|
||||
air_date?: string
|
||||
// 总集数
|
||||
episode_count?: number
|
||||
// 季名称
|
||||
name?: string
|
||||
// 描述
|
||||
overview?: string
|
||||
// 海报
|
||||
poster_path?: string
|
||||
// 季号
|
||||
season_number?: number
|
||||
// 评分
|
||||
vote_average?: number
|
||||
}
|
||||
|
||||
// TMDB季信息
|
||||
export interface TmdbSeason {
|
||||
// 上映日期
|
||||
@@ -1204,3 +1230,62 @@ export interface TransferQueue {
|
||||
state: string
|
||||
}[]
|
||||
}
|
||||
|
||||
// 探索的数据源
|
||||
export interface DiscoverSource {
|
||||
// 数据源名称
|
||||
name: string
|
||||
// 媒体ID的前缀,不含:
|
||||
mediaid_prefix: string
|
||||
// 媒体数据源API地址
|
||||
api_path: string
|
||||
// 过滤参数
|
||||
filter_params: { [key: string]: any }
|
||||
// 过滤参数UI配置
|
||||
filter_ui: RenderProps[]
|
||||
// UI依赖关系字典
|
||||
depends?: { [key: string]: string[] }
|
||||
}
|
||||
|
||||
// 推荐的数据源
|
||||
export interface RecommendSource {
|
||||
// 数据源名称
|
||||
name: string
|
||||
// 媒体数据源API地址
|
||||
api_path: string
|
||||
}
|
||||
|
||||
// 站点资源分类
|
||||
export interface SiteCategory {
|
||||
id: number
|
||||
cat: string
|
||||
desc: string
|
||||
}
|
||||
|
||||
// 工作流
|
||||
export interface Workflow {
|
||||
// 工作流ID
|
||||
id?: string
|
||||
// 工作流名称
|
||||
name?: string
|
||||
// 工作流描述
|
||||
description?: string
|
||||
// 定时器
|
||||
timer?: string
|
||||
// 状态
|
||||
state?: string
|
||||
// 当前执行动作
|
||||
current_action?: string
|
||||
// 任务执行结果
|
||||
result?: string
|
||||
// 已执行次数
|
||||
run_count?: number
|
||||
// 动作列表
|
||||
actions?: any[]
|
||||
// 动作流
|
||||
flows?: any[]
|
||||
// 创建时间
|
||||
add_time?: string
|
||||
// 最后执行时间
|
||||
last_time?: string
|
||||
}
|
||||
|
||||
BIN
src/assets/images/logos/bangumi_title.png
Normal file
BIN
src/assets/images/logos/bangumi_title.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
BIN
src/assets/images/logos/douban_title.png
Normal file
BIN
src/assets/images/logos/douban_title.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
1
src/assets/images/logos/tmdb_title.svg
Normal file
1
src/assets/images/logos/tmdb_title.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 273.42 35.52"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="17.76" x2="273.42" y2="17.76" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>Asset 3</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M191.85,35.37h63.9A17.67,17.67,0,0,0,273.42,17.7h0A17.67,17.67,0,0,0,255.75,0h-63.9A17.67,17.67,0,0,0,174.18,17.7h0A17.67,17.67,0,0,0,191.85,35.37ZM10.1,35.42h7.8V6.92H28V0H0v6.9H10.1Zm28.1,0H46V8.25h.1L55.05,35.4h6L70.3,8.25h.1V35.4h7.8V0H66.45l-8.2,23.1h-.1L50,0H38.2ZM89.14.12h11.7a33.56,33.56,0,0,1,8.08,1,18.52,18.52,0,0,1,6.67,3.08,15.09,15.09,0,0,1,4.53,5.52,18.5,18.5,0,0,1,1.67,8.25,16.91,16.91,0,0,1-1.62,7.58,16.3,16.3,0,0,1-4.38,5.5,19.24,19.24,0,0,1-6.35,3.37,24.53,24.53,0,0,1-7.55,1.15H89.14Zm7.8,28.2h4a21.66,21.66,0,0,0,5-.55A10.58,10.58,0,0,0,110,26a8.73,8.73,0,0,0,2.68-3.35,11.9,11.9,0,0,0,1-5.08,9.87,9.87,0,0,0-1-4.52,9.17,9.17,0,0,0-2.63-3.18A11.61,11.61,0,0,0,106.22,8a17.06,17.06,0,0,0-4.68-.63h-4.6ZM133.09.12h13.2a32.87,32.87,0,0,1,4.63.33,12.66,12.66,0,0,1,4.17,1.3,7.94,7.94,0,0,1,3,2.72,8.34,8.34,0,0,1,1.15,4.65,7.48,7.48,0,0,1-1.67,5,9.13,9.13,0,0,1-4.43,2.82V17a10.28,10.28,0,0,1,3.18,1,8.51,8.51,0,0,1,2.45,1.85,7.79,7.79,0,0,1,1.57,2.62,9.16,9.16,0,0,1,.55,3.2,8.52,8.52,0,0,1-1.2,4.68,9.32,9.32,0,0,1-3.1,3A13.38,13.38,0,0,1,152.32,35a22.5,22.5,0,0,1-4.73.5h-14.5Zm7.8,14.15h5.65a7.65,7.65,0,0,0,1.78-.2,4.78,4.78,0,0,0,1.57-.65,3.43,3.43,0,0,0,1.13-1.2,3.63,3.63,0,0,0,.42-1.8A3.3,3.3,0,0,0,151,8.6a3.42,3.42,0,0,0-1.23-1.13A6.07,6.07,0,0,0,148,6.9a9.9,9.9,0,0,0-1.85-.18h-5.3Zm0,14.65h7a8.27,8.27,0,0,0,1.83-.2,4.67,4.67,0,0,0,1.67-.7,3.93,3.93,0,0,0,1.23-1.3,3.8,3.8,0,0,0,.47-1.95,3.16,3.16,0,0,0-.62-2,4,4,0,0,0-1.58-1.18,8.23,8.23,0,0,0-2-.55,15.12,15.12,0,0,0-2.05-.15h-5.9Z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -2,7 +2,7 @@
|
||||
import { CustomRule } from '@/api/types'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import filter_svg from '@images/svg/filter.svg'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { innerFilterRules } from '@/api/constants'
|
||||
|
||||
// 输入参数
|
||||
@@ -104,7 +104,7 @@ function onClose() {
|
||||
<VImg :src="filter_svg" cover class="mt-7" max-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-model="ruleInfoDialog" scrollable max-width="40rem" persistent>
|
||||
<VDialog v-if="ruleInfoDialog" v-model="ruleInfoDialog" scrollable max-width="40rem" persistent>
|
||||
<VCard :title="`${props.rule.id} - 配置`" class="rounded-t">
|
||||
<DialogCloseBtn v-model="ruleInfoDialog" />
|
||||
<VDivider />
|
||||
|
||||
@@ -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}】`)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -172,7 +172,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-model="downloaderInfoDialog" scrollable max-width="40rem" persistent>
|
||||
<VDialog v-if="downloaderInfoDialog" v-model="downloaderInfoDialog" scrollable max-width="40rem" persistent>
|
||||
<VCard :title="`${props.downloader.name} - 配置`" class="rounded-t">
|
||||
<DialogCloseBtn v-model="downloaderInfoDialog" />
|
||||
<VDivider />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { innerFilterRules } from '@/api/constants'
|
||||
import { CustomRule } from '@/api/types'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
|
||||
@@ -6,7 +6,7 @@ import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
|
||||
import filter_group_svg from '@images/svg/filter-group.svg'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -220,7 +220,7 @@ function onClose() {
|
||||
<VImg :src="filter_group_svg" cover class="mt-10" max-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-model="groupInfoDialog" scrollable max-width="80rem" persistent>
|
||||
<VDialog v-if="groupInfoDialog" v-model="groupInfoDialog" scrollable max-width="80rem" persistent>
|
||||
<VCard :title="`${props.group.name} - 配置`" class="rounded-t">
|
||||
<DialogCloseBtn v-model="groupInfoDialog" />
|
||||
<VDivider />
|
||||
|
||||
@@ -91,7 +91,17 @@ async function drawImages(imageList: string[]) {
|
||||
const img = new Image()
|
||||
img.setAttribute('crossorigin', 'anonymous')
|
||||
img.src = imgSrc
|
||||
await new Promise(resolve => (img.onload = resolve))
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve()
|
||||
img.onerror = () => reject(new Error(`Failed to load image: ${imgSrc}`))
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
ctx.fillStyle = '#e5e7eb'
|
||||
ctx.fillRect(MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1), MARGIN_HEIGHT, POSTER_WIDTH, POSTER_HEIGHT)
|
||||
return
|
||||
}
|
||||
|
||||
const x = MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1)
|
||||
const y = MARGIN_HEIGHT
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
import type { PropType, Ref } from 'vue'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
|
||||
import { formatSeason } from '@/@core/utils/formatters'
|
||||
import { formatSeason, formatRating } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { MediaInfo, NotExistMediaInfo, Subscribe, TmdbSeason } 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()
|
||||
@@ -55,10 +57,10 @@ const subscribeEditDialog = ref(false)
|
||||
const subscribeId = ref<number>()
|
||||
|
||||
// 季详情
|
||||
const seasonInfos = ref<TmdbSeason[]>([])
|
||||
const seasonInfos = ref<MediaSeason[]>([])
|
||||
|
||||
// 选中的订阅季
|
||||
const seasonsSelected = ref<TmdbSeason[]>([])
|
||||
const seasonsSelected = ref<MediaSeason[]>([])
|
||||
|
||||
// 来源角标字典
|
||||
const sourceIconDict: { [key: string]: any } = {
|
||||
@@ -73,11 +75,44 @@ const mediaCardRef = ref<HTMLElement | null>(null)
|
||||
// 创建Intersection Observer实例
|
||||
const observer = ref<IntersectionObserver | null>(null)
|
||||
|
||||
// 所有站点
|
||||
const allSites = ref<Site[]>([])
|
||||
|
||||
// 选中的站点
|
||||
const selectedSites = ref<number[]>([])
|
||||
|
||||
// 搜索菜单显示状态
|
||||
const searchMenuShow = ref(false)
|
||||
|
||||
// 查询所有站点
|
||||
async function querySites() {
|
||||
try {
|
||||
const data: Site[] = await api.get('site/')
|
||||
|
||||
// 过滤站点,只有启用的站点才显示
|
||||
allSites.value = data.filter(item => item.is_active)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询用户选中的站点
|
||||
async function querySelectedSites() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/IndexerSites')
|
||||
|
||||
selectedSites.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
if (props.media?.tmdb_id) return `tmdb:${props.media?.tmdb_id}`
|
||||
else if (props.media?.douban_id) return `douban:${props.media?.douban_id}`
|
||||
else return `bangumi:${props.media?.bangumi_id}`
|
||||
else if (props.media?.bangumi_id) return `bangumi:${props.media?.bangumi_id}`
|
||||
else return `${props.media?.mediaid_prefix}:${props.media?.media_id}`
|
||||
}
|
||||
|
||||
// 订阅弹窗选择的多季
|
||||
@@ -97,11 +132,10 @@ function getChipColor(type: string) {
|
||||
|
||||
// 添加订阅处理
|
||||
async function handleAddSubscribe() {
|
||||
if (props.media?.type === '电视剧' && props.media?.tmdb_id) {
|
||||
// TMDB电视剧
|
||||
// 查询TMDB所有季信息
|
||||
if (props.media?.type === '电视剧') {
|
||||
// 查询所有季信息
|
||||
await getMediaSeasons()
|
||||
if (!seasonInfos.value) {
|
||||
if (!seasonInfos.value || seasonInfos.value.length === 0) {
|
||||
$toast.error(`${props.media?.title} 查询剧集信息失败!`)
|
||||
return
|
||||
}
|
||||
@@ -117,11 +151,6 @@ async function handleAddSubscribe() {
|
||||
seasonsSelected.value = []
|
||||
subscribeSeasonDialog.value = true
|
||||
}
|
||||
} else if (props.media?.type === '电视剧') {
|
||||
// 豆瓣电视剧,只会有一季
|
||||
const season = props.media?.season ?? 1
|
||||
// 添加订阅
|
||||
addSubscribe(season)
|
||||
} else {
|
||||
// 电影
|
||||
addSubscribe()
|
||||
@@ -146,6 +175,7 @@ async function addSubscribe(season = 0) {
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
doubanid: props.media?.douban_id,
|
||||
bangumiid: props.media?.bangumi_id,
|
||||
mediaid: props.media?.media_id ? `${props.media?.mediaid_prefix}:${props.media?.media_id}` : '',
|
||||
season,
|
||||
best_version,
|
||||
})
|
||||
@@ -293,17 +323,26 @@ async function checkSeasonsNotExists() {
|
||||
|
||||
// 查询TMDB的所有季信息
|
||||
async function getMediaSeasons() {
|
||||
startNProgress()
|
||||
try {
|
||||
seasonInfos.value = await api.get(`tmdb/seasons/${props.media?.tmdb_id}`)
|
||||
seasonInfos.value = await api.get('media/seasons', {
|
||||
params: {
|
||||
mediaid: getMediaId(),
|
||||
title: props.media?.title,
|
||||
year: props.media?.year,
|
||||
season: props.media?.season,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
doneNProgress()
|
||||
}
|
||||
|
||||
// 查询订阅弹窗规则
|
||||
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'
|
||||
@@ -361,6 +400,8 @@ function goMediaDetail(isHovering = false) {
|
||||
path: '/media',
|
||||
query: {
|
||||
mediaid: getMediaId(),
|
||||
title: props.media?.title,
|
||||
year: props.media?.year,
|
||||
type: props.media?.type,
|
||||
},
|
||||
})
|
||||
@@ -368,6 +409,13 @@ function goMediaDetail(isHovering = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// 点击搜索
|
||||
async function clickSearch() {
|
||||
if (allSites.value?.length > 0) return
|
||||
querySites()
|
||||
querySelectedSites()
|
||||
}
|
||||
|
||||
// 开始搜索
|
||||
function handleSearch() {
|
||||
router.push({
|
||||
@@ -376,7 +424,10 @@ function handleSearch() {
|
||||
keyword: getMediaId(),
|
||||
type: props.media?.type,
|
||||
area: 'title',
|
||||
title: props.media?.title,
|
||||
year: props.media?.year,
|
||||
season: props.media?.season,
|
||||
sites: selectedSites.value.join(','),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -490,7 +541,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%)"
|
||||
>
|
||||
@@ -503,7 +554,31 @@ 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>
|
||||
</VListItem>
|
||||
<VListItem>
|
||||
<VBtn @click="handleSearch" block>搜索</VBtn>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
<IconBtn icon="mdi-heart" :color="isSubscribed ? 'error' : 'white'" @click.stop="handleSubscribe" />
|
||||
</div>
|
||||
</VCardText>
|
||||
@@ -527,7 +602,7 @@ function onRemoveSubscribe() {
|
||||
:class="getChipColor('rating')"
|
||||
class="absolute right-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
|
||||
>
|
||||
{{ props.media?.vote_average }}
|
||||
{{ formatRating(props.media?.vote_average) }}
|
||||
</VChip>
|
||||
<!--来源图标-->
|
||||
<VAvatar
|
||||
@@ -535,7 +610,7 @@ function onRemoveSubscribe() {
|
||||
density="compact"
|
||||
class="absolute bottom-1 right-1"
|
||||
tile
|
||||
v-if="!hover.isHovering && isImageLoaded && props.media?.source"
|
||||
v-if="!hover.isHovering && isImageLoaded && props.media?.source && !imageLoadError"
|
||||
>
|
||||
<VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
|
||||
</VAvatar>
|
||||
@@ -594,7 +669,7 @@ function onRemoveSubscribe() {
|
||||
</VList>
|
||||
</VCardText>
|
||||
<div class="my-2 text-center">
|
||||
<VBtn :disabled="seasonsSelected.length === 0" width="30%" @click="subscribeSeasons">
|
||||
<VBtn size="large" :disabled="seasonsSelected.length === 0" width="30%" @click="subscribeSeasons">
|
||||
{{ seasonsSelected.length === 0 ? '请选择订阅季' : '提交订阅' }}
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import emby_image from '@images/logos/emby.png'
|
||||
import jellyfin_image from '@images/logos/jellyfin.png'
|
||||
import plex_image from '@images/logos/plex.png'
|
||||
import api from '@/api'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
@@ -185,7 +185,7 @@ onMounted(() => {
|
||||
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-model="mediaServerInfoDialog" scrollable max-width="40rem" persistent>
|
||||
<VDialog v-if="mediaServerInfoDialog" v-model="mediaServerInfoDialog" scrollable max-width="40rem" persistent>
|
||||
<VCard :title="`${props.mediaserver.name} - 配置`" class="rounded-t">
|
||||
<DialogCloseBtn v-model="mediaServerInfoDialog" />
|
||||
<VDivider />
|
||||
|
||||
@@ -7,7 +7,7 @@ import synologychat_image from '@images/logos/synologychat.png'
|
||||
import slack_image from '@images/logos/slack.webp'
|
||||
import chrome_image from '@images/logos/chrome.png'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { cloneDeep } from "lodash"
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
@@ -132,7 +132,7 @@ function onClose() {
|
||||
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-model="notificationInfoDialog" scrollable max-width="40rem" persistent>
|
||||
<VDialog v-if="notificationInfoDialog" v-model="notificationInfoDialog" scrollable max-width="40rem" persistent>
|
||||
<VCard :title="`${props.notification.name} - 配置`" class="rounded-t">
|
||||
<DialogCloseBtn v-model="notificationInfoDialog" />
|
||||
<VDivider />
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
@@ -485,42 +415,23 @@ watch(
|
||||
</VHover>
|
||||
|
||||
<!-- 插件配置页面 -->
|
||||
<VDialog v-model="pluginConfigDialog" scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard :title="`${props.plugin?.plugin_name} - 配置`" class="rounded-t">
|
||||
<DialogCloseBtn v-model="pluginConfigDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :model="pluginConfigForm" />
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn v-if="pluginPageItems.length > 0" @click="showPluginInfo" variant="outlined" color="info">
|
||||
查看数据
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn @click="savePluginConf" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 保存 </VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<PluginConfigDialog
|
||||
v-if="pluginConfigDialog"
|
||||
v-model="pluginConfigDialog"
|
||||
:plugin="props.plugin"
|
||||
@save="configDone"
|
||||
@close="pluginConfigDialog = false"
|
||||
@switch="showPluginInfo"
|
||||
/>
|
||||
|
||||
<!-- 插件数据页面 -->
|
||||
<VDialog v-model="pluginInfoDialog" scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard :title="`${props.plugin?.plugin_name}`" class="rounded-t">
|
||||
<DialogCloseBtn v-model="pluginInfoDialog" />
|
||||
<VCardText class="min-h-40">
|
||||
<PageRender @action="loadPluginPage" v-for="(item, index) in pluginPageItems" :key="index" :config="item" />
|
||||
</VCardText>
|
||||
<VFab
|
||||
icon="mdi-cog"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="showPluginConfig"
|
||||
:class="{ 'mb-10': appMode }"
|
||||
/>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<PluginDataDialog
|
||||
v-if="pluginInfoDialog"
|
||||
v-model="pluginInfoDialog"
|
||||
:plugin="props.plugin"
|
||||
@close="pluginInfoDialog = false"
|
||||
@switch="showPluginConfig"
|
||||
/>
|
||||
|
||||
<!-- 进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||
|
||||
@@ -48,7 +48,6 @@ function goPlay(isHovering: boolean | null = false) {
|
||||
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
@click.stop="goPlay(hover.isHovering)"
|
||||
>
|
||||
<VImg
|
||||
aspect-ratio="2/3"
|
||||
@@ -79,7 +78,7 @@ function goPlay(isHovering: boolean | null = false) {
|
||||
v-show="hover.isHovering || imageLoadError"
|
||||
class="w-full h-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2 pb-5"
|
||||
style="background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)"
|
||||
@click.stop=""
|
||||
@click.stop="goPlay(hover.isHovering)"
|
||||
>
|
||||
<span class="font-bold">{{ props.media?.subtitle }}</span>
|
||||
<h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||
|
||||
@@ -26,7 +26,7 @@ const siteIcon = ref<string>('')
|
||||
const $toast = useToast()
|
||||
|
||||
// 测试按钮文字
|
||||
const testButtonText = ref('连通性测试')
|
||||
const testButtonText = ref('测试连通性')
|
||||
|
||||
// 测试按钮可用性
|
||||
const testButtonDisable = ref(false)
|
||||
@@ -65,7 +65,7 @@ async function testSite() {
|
||||
if (result.success) $toast.success(`${cardProps.site?.name} 连通性测试成功,可正常使用!`)
|
||||
else $toast.error(`${cardProps.site?.name} 连通性测试失败:${result.message}`)
|
||||
|
||||
testButtonText.value = '连通性测试'
|
||||
testButtonText.value = '测试连通性'
|
||||
testButtonDisable.value = false
|
||||
|
||||
getSiteStats()
|
||||
@@ -153,7 +153,8 @@ onMounted(() => {
|
||||
<VCard
|
||||
:variant="cardProps.site?.is_active ? 'elevated' : 'outlined'"
|
||||
class="overflow-hidden h-full flex flex-col"
|
||||
@click="siteEditDialog = true"
|
||||
@click="handleResourceBrowse"
|
||||
:ripple="false"
|
||||
>
|
||||
<template #image>
|
||||
<VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0">
|
||||
@@ -162,10 +163,10 @@ onMounted(() => {
|
||||
</template>
|
||||
<VCardItem style="padding-block-end: 0">
|
||||
<VCardTitle class="font-bold">
|
||||
<span @click.stop="openSitePage">{{ cardProps.site?.name }}</span>
|
||||
{{ cardProps.site?.name }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle>
|
||||
<span @click.stop="openSitePage">{{ cardProps.site?.url }}</span>
|
||||
{{ cardProps.site?.url }}
|
||||
</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VCardText class="py-1">
|
||||
@@ -191,15 +192,24 @@ onMounted(() => {
|
||||
</VTooltip>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<div class="text-sm">
|
||||
↑ {{ formatFileSize(cardProps.data?.upload || 0) }} / ↓ {{ formatFileSize(cardProps.data?.download || 0) }}
|
||||
</div>
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-chevron-down" color="primary" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem variant="plain" v-if="!cardProps.site?.public" @click="handleSiteUpdate">
|
||||
<VListItem variant="plain" @click="siteEditDialog = true" base-color="info">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-refresh" />
|
||||
<VIcon icon="mdi-file-edit-outline" />
|
||||
</template>
|
||||
<VListItemTitle>更新 Cookie & UA</VListItemTitle>
|
||||
<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>
|
||||
@@ -207,24 +217,21 @@ onMounted(() => {
|
||||
</template>
|
||||
<VListItemTitle>{{ testButtonText }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem variant="plain" @click="handleResourceBrowse">
|
||||
<VListItem variant="plain" v-if="!cardProps.site?.public" @click="handleSiteUpdate">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-web" />
|
||||
<VIcon icon="mdi-refresh" />
|
||||
</template>
|
||||
<VListItemTitle>资源预览</VListItemTitle>
|
||||
<VListItemTitle>更新 Cookie & UA</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem variant="plain" @click="handleSiteUserData">
|
||||
<VListItem variant="plain" @click="openSitePage">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-chart-bell-curve" />
|
||||
<VIcon icon="mdi-open-in-new" />
|
||||
</template>
|
||||
<VListItemTitle>站点数据</VListItemTitle>
|
||||
<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" />
|
||||
|
||||
@@ -142,12 +142,22 @@ async function editSubscribeDialog() {
|
||||
subscribeEditDialog.value = true
|
||||
}
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
|
||||
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
|
||||
else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}`
|
||||
else return props.media?.mediaid
|
||||
}
|
||||
|
||||
// 查看媒体详情
|
||||
async function viewMediaDetail() {
|
||||
router.push({
|
||||
path: '/media',
|
||||
query: {
|
||||
mediaid: `${props.media?.tmdbid ? `tmdb:${props.media?.tmdbid}` : `douban:${props.media?.doubanid}`}`,
|
||||
mediaid: getMediaId(),
|
||||
title: props.media?.name,
|
||||
year: props.media?.year,
|
||||
type: props.media?.type,
|
||||
},
|
||||
})
|
||||
@@ -293,6 +303,7 @@ function onSubscribeEditRemove() {
|
||||
}"
|
||||
min-height="170"
|
||||
@click="editSubscribeDialog"
|
||||
:ripple="false"
|
||||
>
|
||||
<div class="me-n3 absolute top-1 right-2">
|
||||
<IconBtn>
|
||||
|
||||
@@ -54,12 +54,20 @@ const posterUrl = computed(() => {
|
||||
return url
|
||||
})
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
|
||||
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
|
||||
}
|
||||
|
||||
// 查看媒体详情
|
||||
async function viewMediaDetail() {
|
||||
router.push({
|
||||
path: '/media',
|
||||
query: {
|
||||
mediaid: `${props.media?.tmdbid ? `tmdb:${props.media?.tmdbid}` : `douban:${props.media?.doubanid}`}`,
|
||||
mediaid: getMediaId(),
|
||||
title: props.media?.name,
|
||||
year: props.media?.year,
|
||||
type: props.media?.type,
|
||||
},
|
||||
})
|
||||
@@ -73,28 +81,26 @@ function showForkSubscribe() {
|
||||
// 完成复用订阅
|
||||
function finishForkSubscribe(subid: number) {
|
||||
subscribeId.value = subid
|
||||
forkSubscribeDialog.value=false
|
||||
forkSubscribeDialog.value = false
|
||||
subscribeEditDialog.value = true
|
||||
}
|
||||
|
||||
// 删除订阅分享时处理
|
||||
function doDelete() {
|
||||
forkSubscribeDialog.value=false
|
||||
forkSubscribeDialog.value = false
|
||||
// 通知父组件刷新
|
||||
emit('delete')
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="h-full">
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col rounded-lg"
|
||||
class="flex flex-col rounded-lg h-full"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
|
||||
}"
|
||||
@@ -111,8 +117,8 @@ function doDelete() {
|
||||
<div class="absolute inset-0 subscribe-card-background"></div>
|
||||
</VImg>
|
||||
</template>
|
||||
<div>
|
||||
<VCardText class="flex items-center pb-1">
|
||||
<div class="h-full flex flex-col">
|
||||
<VCardText class="flex items-center pb-1 grow">
|
||||
<div class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md shadow-lg" v-if="imageLoaded">
|
||||
<VImg :src="posterUrl" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
|
||||
<template #placeholder>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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'
|
||||
@@ -22,10 +22,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'])
|
||||
@@ -161,14 +161,7 @@ onMounted(() => {
|
||||
</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" color="primary" class="me-4" @click="editUser"> 编辑 </VBtn>
|
||||
<VBtn
|
||||
v-if="currentUserIsSuperuser && props.user.id != currentLoginUserId"
|
||||
color="error"
|
||||
|
||||
318
src/components/cards/WorkflowTaskCard.vue
Normal file
318
src/components/cards/WorkflowTaskCard.vue
Normal file
@@ -0,0 +1,318 @@
|
||||
<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">
|
||||
<VCard class="mx-auto h-full" @click="handleFlow(workflow)" :ripple="false" :loading="loading">
|
||||
<VCardItem class="py-3" :class="`bg-${resolveStatusVariant(workflow?.state).color}`">
|
||||
<template #prepend>
|
||||
<VAvatar variant="text" class="me-2">
|
||||
<VIcon
|
||||
v-if="workflow?.state === 'P'"
|
||||
color="success"
|
||||
size="x-large"
|
||||
icon="mdi-play"
|
||||
@click.stop="handleEnable(workflow)"
|
||||
/>
|
||||
<VIcon v-else color="warning" icon="mdi-pause" size="x-large" @click.stop="handlePause(workflow)" />
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle class="text-white">
|
||||
{{ workflow?.name }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle class="text-white">{{ workflow?.description }}</VCardSubtitle>
|
||||
<template #append>
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-vector-polyline-edit" @click.stop="handleFlow(workflow)" />
|
||||
</IconBtn>
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem variant="plain" base-color="primary" @click="handleEdit(workflow)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-note-edit" />
|
||||
</template>
|
||||
<VListItemTitle>编辑任务</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem
|
||||
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>
|
||||
<!-- 流程对话框 -->
|
||||
<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>
|
||||
@@ -26,6 +26,58 @@ const processing = ref(false)
|
||||
// 删除中
|
||||
const deleting = ref(false)
|
||||
|
||||
// 是否折叠
|
||||
const isExpanded = ref(false)
|
||||
|
||||
// follow用户列表
|
||||
const followUsers = ref<string[]>([])
|
||||
|
||||
// 当前用户是否已follow
|
||||
const isFollowed = computed(() => followUsers.value.includes(props.media?.share_uid || ''))
|
||||
|
||||
// 折叠展开
|
||||
function toggleExpand() {
|
||||
isExpanded.value = !isExpanded.value
|
||||
}
|
||||
|
||||
// 加载follow用户列表
|
||||
async function queryFollowUsers() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/FollowSubscribers')
|
||||
followUsers.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// follow用户
|
||||
async function followUser() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post(`subscribe/follow?share_uid=${props.media?.share_uid}`)
|
||||
if (result.success) {
|
||||
queryFollowUsers()
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// unfollow用户
|
||||
async function unfollowUser() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.delete('subscribe/follow', {
|
||||
params: {
|
||||
share_uid: props.media?.share_uid,
|
||||
},
|
||||
})
|
||||
if (result.success) {
|
||||
queryFollowUsers()
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算海报图片地址
|
||||
const posterUrl = computed(() => {
|
||||
const url = props.media?.poster
|
||||
@@ -35,12 +87,20 @@ const posterUrl = computed(() => {
|
||||
return url
|
||||
})
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
|
||||
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
|
||||
}
|
||||
|
||||
// 查看媒体详情
|
||||
async function viewMediaDetail() {
|
||||
router.push({
|
||||
path: '/media',
|
||||
query: {
|
||||
mediaid: `${props.media?.tmdbid ? `tmdb:${props.media?.tmdbid}` : `douban:${props.media?.doubanid}`}`,
|
||||
mediaid: getMediaId(),
|
||||
title: props.media?.name,
|
||||
year: props.media?.year,
|
||||
type: props.media?.type,
|
||||
},
|
||||
})
|
||||
@@ -97,9 +157,13 @@ async function doDelete() {
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
queryFollowUsers()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VDialog max-width="40rem">
|
||||
<VDialog max-width="40rem" scrollable>
|
||||
<VCard>
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VCardText>
|
||||
@@ -123,11 +187,13 @@ async function doDelete() {
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<VCardItem>
|
||||
<VCardTitle class="text-center text-md-left">
|
||||
<VCardTitle
|
||||
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-2 overflow-hidden text-ellipsis"
|
||||
>
|
||||
{{ props.media?.share_title }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle
|
||||
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-2 overflow-hidden text-ellipsis ..."
|
||||
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis"
|
||||
>
|
||||
{{ props.media?.share_comment }}
|
||||
</VCardSubtitle>
|
||||
@@ -144,9 +210,12 @@ async function doDelete() {
|
||||
<span class="text-body-1"> {{ media?.keyword }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem class="ps-0" v-if="media?.custom_words">
|
||||
<VListItem class="ps-0" v-if="media?.custom_words" @click.stop="toggleExpand">
|
||||
<VListItemTitle
|
||||
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-10 overflow-hidden text-ellipsis ..."
|
||||
class="text-center text-md-left break-words whitespace-break-spaces"
|
||||
:class="{
|
||||
'line-clamp-4 overflow-hidden text-ellipsis': !isExpanded,
|
||||
}"
|
||||
>
|
||||
<span class="font-weight-medium">识别词:</span>
|
||||
<span class="text-body-1"> {{ media?.custom_words }}</span>
|
||||
@@ -162,7 +231,7 @@ async function doDelete() {
|
||||
prepend-icon="mdi-heart"
|
||||
:loading="processing"
|
||||
>
|
||||
添加到我的订阅
|
||||
订阅
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="props.media?.share_uid && props.media?.share_uid === globalSettings.USER_UNIQUE_ID"
|
||||
@@ -175,6 +244,24 @@ async function doDelete() {
|
||||
>
|
||||
取消分享
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-else-if="isFollowed && props.media?.share_uid"
|
||||
color="warning"
|
||||
@click="unfollowUser"
|
||||
prepend-icon="mdi-account-remove"
|
||||
class="ms-2"
|
||||
>
|
||||
取消关注
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-else-if="props.media?.share_uid"
|
||||
@click="followUser"
|
||||
color="info"
|
||||
prepend-icon="mdi-account-plus"
|
||||
class="ms-2"
|
||||
>
|
||||
关注
|
||||
</VBtn>
|
||||
</div>
|
||||
<div class="text-xs mt-2" v-if="props.media?.count">
|
||||
<VIcon icon="mdi-fire" />共 {{ props.media?.count?.toLocaleString() }} 次复用
|
||||
|
||||
109
src/components/dialog/PluginConfigDialog.vue
Normal file
109
src/components/dialog/PluginConfigDialog.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import api from '@/api'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import FormRender from '../render/FormRender.vue'
|
||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
plugin: {
|
||||
type: Object as PropType<Plugin>,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close', 'save', 'switch'])
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 插件配置表单数据
|
||||
const pluginConfigForm = ref({})
|
||||
|
||||
// 插件表单配置项
|
||||
let pluginFormItems = reactive([])
|
||||
|
||||
// 进度框
|
||||
const progressDialog = ref(false)
|
||||
|
||||
// 进度文字
|
||||
const progressText = ref('')
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 是否刷新
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
// 调用API读取表单页面
|
||||
async function loadPluginForm() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(`plugin/form/${props.plugin?.id}`)
|
||||
if (result) {
|
||||
pluginFormItems = result.conf
|
||||
if (result.model) pluginConfigForm.value = result.model
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
isRefreshed.value = true
|
||||
}
|
||||
|
||||
// 调用API读取配置数据
|
||||
async function loadPluginConf() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(`plugin/${props.plugin?.id}`)
|
||||
if (!isNullOrEmptyObject(result)) pluginConfigForm.value = result
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
isRefreshed.value = true
|
||||
}
|
||||
|
||||
// 调用API保存配置数据
|
||||
async function savePluginConf() {
|
||||
// 显示等待提示框
|
||||
progressDialog.value = true
|
||||
progressText.value = `正在保存 ${props.plugin?.plugin_name} 配置...`
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.put(`plugin/${props.plugin?.id}`, pluginConfigForm.value)
|
||||
if (result.success) {
|
||||
progressDialog.value = false
|
||||
$toast.success(`插件 ${props.plugin?.plugin_name} 配置已保存`)
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
} else {
|
||||
progressDialog.value = false
|
||||
$toast.error(`插件 ${props.plugin?.plugin_name} 配置保存失败:${result.message}}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeMount(async () => {
|
||||
await loadPluginForm()
|
||||
await loadPluginConf()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VDialog scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard :title="`${props.plugin?.plugin_name} - 配置`" class="rounded-t">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
<VCardText v-if="isRefreshed">
|
||||
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :model="pluginConfigForm" />
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn v-if="props.plugin?.has_page" @click="emit('switch')" variant="outlined" color="info"> 查看数据 </VBtn>
|
||||
<VSpacer />
|
||||
<VBtn @click="savePluginConf" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 保存 </VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
<!-- 进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||
</VDialog>
|
||||
</template>
|
||||
63
src/components/dialog/PluginDataDialog.vue
Normal file
63
src/components/dialog/PluginDataDialog.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import PageRender from '@/components/render/PageRender.vue'
|
||||
import api from '@/api'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
plugin: {
|
||||
type: Object as PropType<Plugin>,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close', 'save', 'switch'])
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
// APP
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
|
||||
// 是否刷新
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
// 插件数据页面配置项
|
||||
let pluginPageItems = ref([])
|
||||
|
||||
// 调用API读取数据页面
|
||||
async function loadPluginPage() {
|
||||
try {
|
||||
const result: [] = await api.get(`plugin/page/${props.plugin?.id}`)
|
||||
if (result) pluginPageItems.value = result
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
isRefreshed.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPluginPage()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard :title="`${props.plugin?.plugin_name}`" class="rounded-t">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
|
||||
<VCardText v-else class="min-h-40">
|
||||
<PageRender @action="loadPluginPage" v-for="(item, index) in pluginPageItems" :key="index" :config="item" />
|
||||
</VCardText>
|
||||
<VFab
|
||||
icon="mdi-cog"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="emit('switch')"
|
||||
:class="{ 'mb-10': appMode }"
|
||||
/>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -1,19 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { Site } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import api from '@/api'
|
||||
import type { TorrentInfo } from '@/api/types'
|
||||
import type { TorrentInfo, SiteCategory } from '@/api/types'
|
||||
import { formatFileSize } from '@core/utils/formatters'
|
||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
site: Object as PropType<Site>,
|
||||
})
|
||||
|
||||
// 关键字
|
||||
const keyword = ref<string>()
|
||||
|
||||
// 选择分类
|
||||
const selectCategory = ref<number[]>([])
|
||||
|
||||
// 全部分类
|
||||
const siteCategoryList = ref<SiteCategory[]>()
|
||||
|
||||
// 分类选项
|
||||
const categoryOptions = computed(() => {
|
||||
return siteCategoryList.value?.map(item => {
|
||||
return { title: item.desc, value: item.id }
|
||||
})
|
||||
})
|
||||
|
||||
// 注册事件
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
@@ -55,17 +67,6 @@ async function downloadTorrentFile(enclosure: string) {
|
||||
window.open(enclosure, '_blank')
|
||||
}
|
||||
|
||||
// 调用API,查询站点资源
|
||||
async function getResourceList() {
|
||||
resourceLoading.value = true
|
||||
try {
|
||||
resourceDataList.value = await api.get(`site/resource/${props.site?.id}`)
|
||||
resourceLoading.value = false
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 促销Chip类
|
||||
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
||||
if (downloadVolume === 0) return 'text-white bg-lime-500'
|
||||
@@ -93,17 +94,75 @@ function addDownloadError(error: string) {
|
||||
addDownloadDialog.value = false
|
||||
}
|
||||
|
||||
// 调用API,查询站点资源
|
||||
async function getResourceList() {
|
||||
resourceLoading.value = true
|
||||
try {
|
||||
resourceDataList.value = await api.get(`site/resource/${props.site?.id}`, {
|
||||
params: {
|
||||
keyword: keyword.value,
|
||||
cat: selectCategory.value?.join(','),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
resourceLoading.value = false
|
||||
}
|
||||
|
||||
// 加载站点分类
|
||||
async function getSiteCategoryList() {
|
||||
try {
|
||||
siteCategoryList.value = await api.get(`site/category/${props.site?.id}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
onMounted(() => {
|
||||
getSiteCategoryList()
|
||||
getResourceList()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VDialog max-width="80rem" scrollable z-index="1010" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard :title="`浏览 - ${props.site?.name}`">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
<VCardText class="pt-2">
|
||||
<VDialog scrollable fullscreen :scrim="false" transition="dialog-bottom-transition">
|
||||
<VCard>
|
||||
<!-- Toolbar -->
|
||||
<div>
|
||||
<VToolbar color="primary">
|
||||
<VToolbarTitle>{{ `浏览 - ${props.site?.name}` }}</VToolbarTitle>
|
||||
<VSpacer />
|
||||
<VToolbarItems>
|
||||
<VBtn icon variant="plain" @click="emit('close')" class="me-3">
|
||||
<VIcon size="large" color="white" icon="ri-close-line" />
|
||||
</VBtn>
|
||||
</VToolbarItems>
|
||||
</VToolbar>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<VRow>
|
||||
<VCol cols="6" md="5">
|
||||
<VTextField v-model="keyword" size="small" density="compact" label="搜索关键字" clearable />
|
||||
</VCol>
|
||||
<VCol cols="6" md="5">
|
||||
<VSelect
|
||||
v-model="selectCategory"
|
||||
:items="categoryOptions"
|
||||
size="small"
|
||||
density="compact"
|
||||
chips
|
||||
label="资源分类"
|
||||
multiple
|
||||
clearable
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="2" class="text-center">
|
||||
<VBtn block prepend-icon="mdi-magnify" @click="getResourceList">搜索</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
<VCardText class="px-0 py-0 my-0">
|
||||
<VDataTable
|
||||
v-model:items-per-page="resourceItemsPerPage"
|
||||
:headers="resourceHeaders"
|
||||
@@ -119,6 +178,7 @@ onMounted(() => {
|
||||
items-per-page-text="每页条数"
|
||||
page-text="{0}-{1} 共 {2} 条"
|
||||
loading-text="加载中..."
|
||||
class="h-full"
|
||||
>
|
||||
<template #item.title="{ item }">
|
||||
<a href="javascript:void(0)" @click.stop="addDownload(item)">
|
||||
|
||||
@@ -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('')
|
||||
@@ -199,13 +202,15 @@ async function updateUser() {
|
||||
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 {
|
||||
@@ -362,7 +367,7 @@ onMounted(() => {
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VDivider class="my-10">
|
||||
<span>消息账号绑定</span>
|
||||
<span>账号绑定</span>
|
||||
</VDivider>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -395,6 +400,9 @@ onMounted(() => {
|
||||
label="SynologyChat用户"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField v-model="userForm.settings.douban_userid" density="comfortable" clearable label="豆瓣用户" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
|
||||
303
src/components/dialog/WorkflowActionsDialog.vue
Normal file
303
src/components/dialog/WorkflowActionsDialog.vue
Normal file
@@ -0,0 +1,303 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { VueFlow, useVueFlow } from '@vue-flow/core'
|
||||
import { MiniMap } from '@vue-flow/minimap'
|
||||
import useDragAndDrop from '@core/utils/workflow'
|
||||
import { Workflow } from '@/api/types'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import api from '@/api'
|
||||
import WorkflowSidebar from '@/layouts/components/WorkflowSidebar.vue'
|
||||
import DropzoneBackground from '@/layouts/components/DropzoneBackground.vue'
|
||||
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
|
||||
|
||||
const { onConnect, addEdges, nodes, edges } = useVueFlow()
|
||||
|
||||
const { onDragOver, onDrop, onDragLeave, isDragOver } = useDragAndDrop()
|
||||
|
||||
onConnect(addEdges)
|
||||
|
||||
// 自定义节点类型
|
||||
const nodeTypes: Record<string, any> = ref({})
|
||||
|
||||
// 自动扫描目录下所有的 .vue 文件
|
||||
const components = import.meta.glob('../workflow/*Action.vue')
|
||||
|
||||
// 动态加载某个组件
|
||||
const loadComponent = async (componentName: string) => {
|
||||
const component = components[`../workflow/${componentName}.vue`]
|
||||
if (component) {
|
||||
return ((await component()) as any).default
|
||||
}
|
||||
throw new Error(`组件 ${componentName} 未找到`)
|
||||
}
|
||||
|
||||
// 将所有components中的组件加载到nodeTypes中
|
||||
for (const path in components) {
|
||||
const componentName = path.match(/\.\/workflow\/(.*).vue$/)?.[1]
|
||||
if (!componentName) {
|
||||
continue
|
||||
}
|
||||
loadComponent(componentName).then(component => {
|
||||
nodeTypes.value[componentName] = markRaw(component)
|
||||
})
|
||||
}
|
||||
|
||||
// 定义输入参数
|
||||
const props = defineProps({
|
||||
workflow: Object as PropType<Workflow>,
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close', 'save'])
|
||||
|
||||
// 站点编辑表单数据
|
||||
const workflowForm = ref<any>(props.workflow || {})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 导入代码对话框
|
||||
const importCodeDialog = ref(false)
|
||||
|
||||
// 调用API 编辑任务
|
||||
async function updateWorkflow() {
|
||||
// 更新节点和流程
|
||||
workflowForm.value.actions = nodes
|
||||
workflowForm.value.flows = edges
|
||||
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.put(`workflow/${workflowForm.value.id}`, workflowForm.value)
|
||||
if (result.success) {
|
||||
$toast.success(`保存任务流程成功!`)
|
||||
emit('save')
|
||||
} else {
|
||||
$toast.error(`保存任务流程失败:${result.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存导入的代码,直接覆盖原有值
|
||||
function saveCodeString(type: string, code: any) {
|
||||
try {
|
||||
if (code) {
|
||||
const codeObject = JSON.parse(code.value)
|
||||
if (type === 'workflow') {
|
||||
nodes.value = codeObject.actions || []
|
||||
edges.value = codeObject.flows || []
|
||||
}
|
||||
importCodeDialog.value = false
|
||||
$toast.success('导入成功!')
|
||||
}
|
||||
} catch (error) {
|
||||
$toast.error('导入失败!')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 分享工作流程
|
||||
function shareWorkflow() {
|
||||
const codeString = JSON.stringify({ actions: nodes.value, flows: edges.value })
|
||||
navigator.clipboard.writeText(codeString)
|
||||
$toast.success('任务流程代码已复制到剪贴板!')
|
||||
}
|
||||
|
||||
// 删除选中节点或连接线
|
||||
const deleteSelectedNodeOrEdge = () => {
|
||||
// 删除选中的节点
|
||||
const selectedNode = nodes.value.find((node: { selected: any }) => node.selected)
|
||||
if (selectedNode) {
|
||||
// 删除节点
|
||||
nodes.value = nodes.value.filter((node: { id: any }) => node.id !== selectedNode.id)
|
||||
// 删除与该节点相关的 edges
|
||||
edges.value = edges.value.filter(
|
||||
(edge: { source: any; target: any }) => edge.source !== selectedNode.id && edge.target !== selectedNode.id,
|
||||
)
|
||||
}
|
||||
// 删除选中的连接线
|
||||
const selectedEdge = edges.value.find((edge: { selected: any }) => edge.selected)
|
||||
if (selectedEdge) {
|
||||
// 删除连接线
|
||||
edges.value = edges.value.filter((edge: { id: any }) => edge.id !== selectedEdge.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 键盘按键事件处理
|
||||
const handleKeyDown = (event: { key: string }) => {
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
deleteSelectedNodeOrEdge()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.workflow) {
|
||||
nodes.value = props.workflow.actions ?? []
|
||||
edges.value = props.workflow.flows ?? []
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog scrollable fullscreen :scrim="false" transition="dialog-bottom-transition">
|
||||
<VCard>
|
||||
<!-- Toolbar -->
|
||||
<div>
|
||||
<VToolbar color="primary">
|
||||
<VToolbarItems>
|
||||
<VBtn icon @click="emit('close')" class="ms-3">
|
||||
<VIcon size="large" color="white" icon="mdi-close" />
|
||||
</VBtn>
|
||||
</VToolbarItems>
|
||||
<VToolbarTitle> 编辑流程 - {{ workflow?.name }} </VToolbarTitle>
|
||||
<VToolbarItems>
|
||||
<VBtn icon @click="importCodeDialog = true">
|
||||
<VIcon size="large" color="white" icon="mdi-import" />
|
||||
</VBtn>
|
||||
<VBtn icon @click="shareWorkflow">
|
||||
<VIcon size="large" color="white" icon="mdi-share" />
|
||||
</VBtn>
|
||||
<VBtn icon @click="updateWorkflow" class="mx-5">
|
||||
<VIcon size="large" color="white" icon="mdi-content-save" />
|
||||
</VBtn>
|
||||
</VToolbarItems>
|
||||
</VToolbar>
|
||||
</div>
|
||||
<VDivider />
|
||||
<VCardText class="px-0 py-0">
|
||||
<div class="dnd-flow" @drop="onDrop">
|
||||
<VueFlow
|
||||
:nodes="nodes"
|
||||
:edges="edges"
|
||||
:nodeTypes="nodeTypes"
|
||||
:default-edge-options="{ type: 'animation', animated: true }"
|
||||
:edge-updater-radius="10"
|
||||
@dragover="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@keydown="handleKeyDown"
|
||||
auto-connect
|
||||
>
|
||||
<MiniMap />
|
||||
<DropzoneBackground
|
||||
:style="{
|
||||
backgroundColor: isDragOver ? '#e7f3ff' : 'transparent',
|
||||
transition: 'background-color 0.2s ease',
|
||||
}"
|
||||
>
|
||||
</DropzoneBackground>
|
||||
</VueFlow>
|
||||
<WorkflowSidebar />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<ImportCodeDialog
|
||||
v-if="importCodeDialog"
|
||||
v-model="importCodeDialog"
|
||||
title="导入任务流程"
|
||||
dataType="workflow"
|
||||
@close="importCodeDialog = false"
|
||||
@save="saveCodeString"
|
||||
/>
|
||||
</VDialog>
|
||||
</template>
|
||||
<style>
|
||||
@import '@vue-flow/core/dist/style.css';
|
||||
@import '@vue-flow/core/dist/theme-default.css';
|
||||
@import '@vue-flow/controls/dist/style.css';
|
||||
@import '@vue-flow/minimap/dist/style.css';
|
||||
@import '@vue-flow/node-resizer/dist/style.css';
|
||||
|
||||
.vue-flow__minimap {
|
||||
transform: scale(75%);
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
|
||||
.dnd-flow {
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dnd-flow aside {
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
border-right: 1px solid #eee;
|
||||
padding: 15px 10px;
|
||||
font-size: 12px;
|
||||
background: #10b981bf;
|
||||
-webkit-box-shadow: 0px 5px 10px 0px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 5px 10px #0000004d;
|
||||
}
|
||||
|
||||
.dnd-flow aside .nodes > * {
|
||||
margin-bottom: 10px;
|
||||
cursor: grab;
|
||||
font-weight: 500;
|
||||
-webkit-box-shadow: 5px 5px 10px 2px rgba(0, 0, 0, 0.25);
|
||||
box-shadow: 5px 5px 10px 2px #00000040;
|
||||
}
|
||||
|
||||
.dnd-flow aside .description {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.dnd-flow .vue-flow-wrapper {
|
||||
flex-grow: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 640px) {
|
||||
.dnd-flow {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.dnd-flow aside {
|
||||
max-width: 25%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 639px) {
|
||||
.dnd-flow aside .nodes {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.dropzone-background {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dropzone-background .overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vue-flow__handle {
|
||||
height: 24px;
|
||||
width: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.vue-flow__edge-path,
|
||||
.vue-flow__connection-path {
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.vue-flow__handle-left {
|
||||
background-color: rgb(var(--v-theme-info));
|
||||
}
|
||||
|
||||
.vue-flow__handle-right {
|
||||
background-color: rgb(var(--v-theme-error));
|
||||
}
|
||||
</style>
|
||||
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" persistent 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>
|
||||
@@ -169,7 +169,7 @@ const sortIcon = computed(() => {
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VDialog v-model="newFolderPopper" max-width="50rem">
|
||||
<VDialog v-if="newFolderPopper" v-model="newFolderPopper" max-width="50rem">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props">
|
||||
<VTooltip text="新建文件夹">
|
||||
|
||||
7
src/components/misc/FilterOption.vue
Normal file
7
src/components/misc/FilterOption.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ title: string }>()
|
||||
</script>
|
||||
<template>
|
||||
<VListSubheader>{{ title }}</VListSubheader>
|
||||
<VListItem><slot /></VListItem>
|
||||
</template>
|
||||
@@ -6,10 +6,21 @@ import { type PropType } from 'vue'
|
||||
const elementProps = defineProps({
|
||||
config: Object as PropType<RenderProps>,
|
||||
})
|
||||
// key
|
||||
const componentKey = ref(0)
|
||||
|
||||
onActivated(() => {
|
||||
componentKey.value++
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Component :is="elementProps.config?.component" v-if="!elementProps.config?.html" v-bind="elementProps.config?.props">
|
||||
<Component
|
||||
:key="componentKey"
|
||||
:is="elementProps.config?.component"
|
||||
v-if="!elementProps.config?.html"
|
||||
v-bind="elementProps.config?.props"
|
||||
>
|
||||
{{ elementProps.config?.text }}
|
||||
<template v-for="(content, name) in elementProps.config?.slots || []" :key="name" v-slot:[name]="{ _props }">
|
||||
<slot :name="name" v-bind="_props">
|
||||
@@ -23,6 +34,7 @@ const elementProps = defineProps({
|
||||
/>
|
||||
</Component>
|
||||
<Component
|
||||
:key="componentKey"
|
||||
:is="elementProps.config?.component"
|
||||
v-if="elementProps.config?.html"
|
||||
v-bind="elementProps.config?.props"
|
||||
|
||||
@@ -16,6 +16,9 @@ defineProps<{
|
||||
const parseProps = (rawProps: Record<string, any>, model: Record<string, any>) => {
|
||||
const parsedProps: Record<string, any> = {}
|
||||
|
||||
const isExpression = (value: string) => value.startsWith('{{') && value.endsWith('}}')
|
||||
const extractExpression = (value: string) => value.slice(2, -2).trim()
|
||||
|
||||
for (const [key, value] of Object.entries(rawProps)) {
|
||||
if (key === 'modelvalue') {
|
||||
// 将 modelvalue 转换为 v-model:value 的形式
|
||||
@@ -23,22 +26,44 @@ const parseProps = (rawProps: Record<string, any>, model: Record<string, any>) =
|
||||
parsedProps['onUpdate:value'] = (newValue: any) => {
|
||||
model[value] = newValue
|
||||
}
|
||||
} else if (key === 'model') {
|
||||
} else if (['model', 'v-model'].includes(key)) {
|
||||
// 处理 v-model
|
||||
parsedProps['modelValue'] = model[value]
|
||||
parsedProps['onUpdate:modelValue'] = (newValue: any) => {
|
||||
model[value] = newValue
|
||||
}
|
||||
} else if (key.startsWith('model:')) {
|
||||
} else if (['show', 'v-show'].includes(key)) {
|
||||
// 处理 v-show,实现显示隐藏
|
||||
const expression = isExpression(value) ? extractExpression(value) : value
|
||||
const isVisible = new Function('model', `with(model) { return ${expression} }`)(model)
|
||||
// 动态设置 style.display
|
||||
if (!parsedProps.style) {
|
||||
parsedProps.style = {}
|
||||
}
|
||||
parsedProps.style.display = isVisible ? '' : 'none'
|
||||
} else if (key.startsWith('model:') || key.startsWith('v-model:')) {
|
||||
// 处理 v-model:<prop>
|
||||
const propName = key.replace('model:', '')
|
||||
const propName = key.split(':')[1]
|
||||
parsedProps[propName] = model[value]
|
||||
parsedProps[`onUpdate:${propName}`] = (newValue: any) => {
|
||||
model[value] = newValue
|
||||
}
|
||||
} else if (key.startsWith('on')) {
|
||||
// 处理事件监听,值是函数的代码
|
||||
const eventName = key.replace('on', '').toLowerCase()
|
||||
parsedProps[eventName] = new Function('model', `with(model) { return ${value} }`)(model)
|
||||
} else {
|
||||
// 普通属性直接赋值
|
||||
parsedProps[key] = typeof value === 'string' && value in model ? model[value] : value
|
||||
// 如果是表达式,需要绑定
|
||||
if (typeof value === 'string' && isExpression(value)) {
|
||||
const expression = extractExpression(value)
|
||||
parsedProps[key] = new Function('model', `with(model) { return ${expression} }`)(model)
|
||||
} else if (typeof value === 'string' && value in model) {
|
||||
// 如果是数据模型的属性,直接绑定
|
||||
parsedProps[key] = model[value]
|
||||
} else {
|
||||
// 其他情况直接赋值
|
||||
parsedProps[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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': 'bangumi/calendar',
|
||||
'name': 'Bangumi每日放送',
|
||||
},
|
||||
{
|
||||
'api_path': 'recommend/tmdb_movies',
|
||||
'name': 'TMDB热门电影',
|
||||
},
|
||||
{
|
||||
'api_path': 'recommend/tmdb_tvs?with_original_language=zh|en|ja|ko',
|
||||
'name': 'TMDB热门电视剧',
|
||||
},
|
||||
{
|
||||
'api_path': 'recommend/douban_movie_hot',
|
||||
'name': '豆瓣热门电影',
|
||||
},
|
||||
{
|
||||
'api_path': 'recommend/douban_tv_hot',
|
||||
'name': '豆瓣热门电视剧',
|
||||
},
|
||||
{
|
||||
'api_path': 'recommend/douban_tv_animation',
|
||||
'name': '豆瓣热门动漫',
|
||||
},
|
||||
{
|
||||
'api_path': 'recommend/douban_movies',
|
||||
'name': '豆瓣最新电影',
|
||||
},
|
||||
{
|
||||
'api_path': 'recommend/douban_tvs',
|
||||
'name': '豆瓣最新电视剧',
|
||||
},
|
||||
{
|
||||
'api_path': 'recommend/douban_movie_top250',
|
||||
'name': '豆瓣电影TOP250',
|
||||
},
|
||||
{
|
||||
'api_path': 'recommend/douban_tv_weekly_chinese',
|
||||
'name': '豆瓣国产剧集榜',
|
||||
},
|
||||
{
|
||||
'api_path': 'recommend/douban_tv_weekly_global',
|
||||
'name': '豆瓣全球剧集榜',
|
||||
},
|
||||
]
|
||||
|
||||
// 额外的数据源
|
||||
const extraRecommendSources = ref<RecommendSource[]>([])
|
||||
|
||||
// 加载额外的发现数据源
|
||||
async function loadExtraRecommendSources() {
|
||||
try {
|
||||
extraRecommendSources.value = await api.get('recommend/source')
|
||||
if (extraRecommendSources.value.length > 0) {
|
||||
innerList.push(
|
||||
...extraRecommendSources.value.map(source => ({
|
||||
api_path: source.api_path,
|
||||
name: source.name,
|
||||
})),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 来源类型下拉框
|
||||
const sourceTypeOptions = [
|
||||
{ value: 'ranking', title: '推荐榜单' },
|
||||
{ value: 'api', title: 'API' },
|
||||
]
|
||||
|
||||
// 计算下拉框
|
||||
const sourceOptions = computed(() => innerList.map(item => item.name))
|
||||
|
||||
onMounted(() => {
|
||||
loadExtraRecommendSources()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard max-width="20rem">
|
||||
<Handle id="edge_in" type="target" :position="Position.Left" />
|
||||
<VCardItem>
|
||||
<template v-slot:prepend>
|
||||
<VAvatar>
|
||||
<VIcon icon="mdi-multimedia" size="x-large"></VIcon>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>获取媒体数据</VCardTitle>
|
||||
<VCardSubtitle>获取榜单等媒体数据列表</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VSelect v-model="data.source_type" :items="sourceTypeOptions" label="来源" outlined dense />
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="data.source_type === 'ranking'">
|
||||
<VCol cols="12">
|
||||
<VSelect
|
||||
v-model="data.sources"
|
||||
:items="sourceOptions"
|
||||
label="选择榜单"
|
||||
chips
|
||||
multiple
|
||||
outlined
|
||||
dense
|
||||
clearable
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="data.api_path"
|
||||
label="API地址"
|
||||
placeholder="/api/v1/plugin/xxx/xxxx"
|
||||
outlined
|
||||
dense
|
||||
clearable
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<Handle id="edge_out" type="source" :position="Position.Right" />
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
51
src/components/workflow/FetchRssAction.vue
Normal file
51
src/components/workflow/FetchRssAction.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
|
||||
defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard max-width="20rem">
|
||||
<Handle id="edge_in" type="target" :position="Position.Left" />
|
||||
<VCardItem>
|
||||
<template v-slot:prepend>
|
||||
<VAvatar>
|
||||
<VIcon icon="mdi-rss" size="x-large"></VIcon>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>获取RSS资源</VCardTitle>
|
||||
<VCardSubtitle>订阅RSS地址获取资源</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="data.url" label="RSS地址" outlined dense clearable />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="data.ua" label="User-Agent" outlined dense clearable />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="data.timeout" type="number" label="超时时间" outlined dense clearable />
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VSwitch v-model="data.match_media" label="匹配媒体信息" />
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VSwitch v-model="data.proxy" label="使用代理" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<Handle id="edge_out" type="source" :position="Position.Right" />
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
118
src/components/workflow/FetchTorrentsAction.vue
Normal file
118
src/components/workflow/FetchTorrentsAction.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { Site } from '@/api/types'
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
|
||||
defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 电影/电视剧下拉框
|
||||
const typeOptions = ref([
|
||||
{
|
||||
title: '电影',
|
||||
value: '电影',
|
||||
},
|
||||
{
|
||||
title: '电视剧',
|
||||
value: '电视剧',
|
||||
},
|
||||
])
|
||||
|
||||
// 搜索方式下拉框
|
||||
const searchOptions = ref([
|
||||
{
|
||||
title: '名称',
|
||||
value: 'keyword',
|
||||
},
|
||||
{
|
||||
title: '媒体列表',
|
||||
value: 'media',
|
||||
},
|
||||
])
|
||||
|
||||
// 站点数据列表
|
||||
const siteList = ref<Site[]>([])
|
||||
|
||||
// 获取站点列表数据
|
||||
async function loadSites() {
|
||||
try {
|
||||
const data: Site[] = await api.get('site/rss')
|
||||
|
||||
// 过滤站点,只有启用的站点才显示
|
||||
siteList.value = data.filter(item => item.is_active)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 站点选项
|
||||
const siteOptions = computed(() => {
|
||||
return siteList.value.map(item => {
|
||||
return {
|
||||
title: item.name,
|
||||
value: item.id,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadSites()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard max-width="20rem">
|
||||
<Handle id="edge_in" type="target" :position="Position.Left" />
|
||||
<VCardItem>
|
||||
<template v-slot:prepend>
|
||||
<VAvatar>
|
||||
<VIcon icon="mdi-search-web" size="x-large"></VIcon>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>搜索站点资源</VCardTitle>
|
||||
<VCardSubtitle>搜索站点种子资源列表</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VSelect v-model="data.search_type" label="搜索方式" :items="searchOptions" outlined dense />
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="data.search_type === 'keyword'">
|
||||
<VCol cols="6">
|
||||
<VTextField v-model="data.name" label="名称" outlined dense />
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField v-model="data.year" label="年份" outlined dense />
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VSelect v-model="data.type" label="类型" :items="typeOptions" outlined dense />
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField v-model="data.season" type="number" label="季" outlined dense />
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VSelect v-model="data.sites" label="站点" :items="siteOptions" chips multiple outlined dense />
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="data.search_type === 'keyword'">
|
||||
<VCol cols="12">
|
||||
<VSwitch v-model="data.match_media" label="匹配媒体信息" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<Handle id="edge_out" type="source" :position="Position.Right" />
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
81
src/components/workflow/FilterMediasAction.vue
Normal file
81
src/components/workflow/FilterMediasAction.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 电影/电视剧下拉框
|
||||
const typeOptions = ref([
|
||||
{
|
||||
title: '电影',
|
||||
value: '电影',
|
||||
},
|
||||
{
|
||||
title: '电视剧',
|
||||
value: '电视剧',
|
||||
},
|
||||
])
|
||||
|
||||
// 二级分类策略
|
||||
const mediaCategories = ref<{ [key: string]: any }>({})
|
||||
|
||||
// 调用API查询自动分类配置
|
||||
async function loadMediaCategories() {
|
||||
try {
|
||||
mediaCategories.value = await api.get('media/category')
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 根据选中的媒体类型,获取对应的媒体类别
|
||||
const getCategories = computed(() => {
|
||||
const default_value = [{ title: '全部', value: '' }]
|
||||
if (!mediaCategories.value || !mediaCategories.value[props.data.type ?? '']) return default_value
|
||||
return default_value.concat(mediaCategories.value[props.data.type ?? ''])
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadMediaCategories()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard max-width="20rem">
|
||||
<Handle id="edge_in" type="target" :position="Position.Left" />
|
||||
<VCardItem>
|
||||
<template v-slot:prepend>
|
||||
<VAvatar>
|
||||
<VIcon icon="mdi-filter-check" size="x-large"></VIcon>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>过滤媒体数据</VCardTitle>
|
||||
<VCardSubtitle>对媒体数据列表进行过滤</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VSelect v-model="data.type" label="类型" :items="typeOptions" outlined dense />
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField v-model="data.year" label="年份" outlined dense />
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField v-model="data.vote" type="number" label="评分" outlined dense />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<Handle id="edge_out" type="source" :position="Position.Right" />
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
176
src/components/workflow/FilterTorrentsAction.vue
Normal file
176
src/components/workflow/FilterTorrentsAction.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { FilterRuleGroup } from '@/api/types'
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
|
||||
defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 质量选择框数据
|
||||
const qualityOptions = ref([
|
||||
{
|
||||
title: '全部',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
title: '蓝光原盘',
|
||||
value: 'Blu-?Ray.+VC-?1|Blu-?Ray.+AVC|UHD.+blu-?ray.+HEVC|MiniBD',
|
||||
},
|
||||
{
|
||||
title: 'Remux',
|
||||
value: 'Remux',
|
||||
},
|
||||
{
|
||||
title: 'BluRay',
|
||||
value: 'Blu-?Ray',
|
||||
},
|
||||
{
|
||||
title: 'UHD',
|
||||
value: 'UHD|UltraHD',
|
||||
},
|
||||
{
|
||||
title: 'WEB-DL',
|
||||
value: 'WEB-?DL|WEB-?RIP',
|
||||
},
|
||||
{
|
||||
title: 'HDTV',
|
||||
value: 'HDTV',
|
||||
},
|
||||
{
|
||||
title: 'H265',
|
||||
value: '[Hx].?265|HEVC',
|
||||
},
|
||||
{
|
||||
title: 'H264',
|
||||
value: '[Hx].?264|AVC',
|
||||
},
|
||||
])
|
||||
|
||||
// 分辨率选择框数据
|
||||
const resolutionOptions = ref([
|
||||
{
|
||||
title: '全部',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
title: '4k',
|
||||
value: '4K|2160p|x2160',
|
||||
},
|
||||
{
|
||||
title: '1080p',
|
||||
value: '1080[pi]|x1080',
|
||||
},
|
||||
{
|
||||
title: '720p',
|
||||
value: '720[pi]|x720',
|
||||
},
|
||||
])
|
||||
|
||||
// 特效选择框数据
|
||||
const effectOptions = ref([
|
||||
{
|
||||
title: '全部',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
title: '杜比视界',
|
||||
value: 'Dolby[\\s.]+Vision|DOVI|[\\s.]+DV[\\s.]+',
|
||||
},
|
||||
{
|
||||
title: '杜比全景声',
|
||||
value: 'Dolby[\\s.]*\\+?Atmos|Atmos',
|
||||
},
|
||||
{
|
||||
title: 'HDR',
|
||||
value: '[\\s.]+HDR[\\s.]+|HDR10|HDR10\\+',
|
||||
},
|
||||
{
|
||||
title: 'SDR',
|
||||
value: '[\\s.]+SDR[\\s.]+',
|
||||
},
|
||||
])
|
||||
|
||||
// 所有规则组列表
|
||||
const filterRuleGroups = ref<FilterRuleGroup[]>([])
|
||||
|
||||
// 加载规则组
|
||||
async function queryFilterRuleGroups() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
|
||||
filterRuleGroups.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算过滤规则组选择框数据
|
||||
const ruleGroupsOptions = computed(() => {
|
||||
return filterRuleGroups.value.map(group => ({
|
||||
title: group.name,
|
||||
value: group.name,
|
||||
}))
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
queryFilterRuleGroups()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard max-width="20rem">
|
||||
<Handle id="edge_in" type="target" :position="Position.Left" />
|
||||
<VCardItem>
|
||||
<template v-slot:prepend>
|
||||
<VAvatar>
|
||||
<VIcon icon="mdi-filter-multiple" size="x-large"></VIcon>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>过滤资源</VCardTitle>
|
||||
<VCardSubtitle>对资源列表数据进行过滤</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="6">
|
||||
<VSelect v-model="data.quality" label="质量" :items="qualityOptions" outlined dense />
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VSelect v-model="data.resolution" label="分辨率" :items="resolutionOptions" outlined dense />
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VSelect v-model="data.effect" label="特效" :items="effectOptions" outlined dense />
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField v-model="data.size" label="大小范围" placeholder="MB" outlined dense />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="data.include" label="包含(关键字、正则式)" outlined dense />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="data.exclude" label="排除(关键字、正则式)" outlined dense />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VSelect
|
||||
v-model="data.rule_groups"
|
||||
chips
|
||||
multiple
|
||||
label="过滤规则组"
|
||||
:items="ruleGroupsOptions"
|
||||
outlined
|
||||
dense
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<Handle id="edge_out" type="source" :position="Position.Right" />
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
43
src/components/workflow/ScanFileAction.vue
Normal file
43
src/components/workflow/ScanFileAction.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import { storageOptions } from '@/api/constants'
|
||||
|
||||
defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard max-width="20rem">
|
||||
<Handle id="edge_in" type="target" :position="Position.Left" />
|
||||
<VCardItem>
|
||||
<template v-slot:prepend>
|
||||
<VAvatar>
|
||||
<VIcon icon="mdi-file-move" size="x-large"></VIcon>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>扫描目录</VCardTitle>
|
||||
<VCardSubtitle>扫描目录文件到队列</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VSelect v-model="data.storage" label="存储" :items="storageOptions" outlined dense />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VPathField v-model="data.directory" :storage="data.storage" label="目录" clearable />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<Handle id="edge_out" type="source" :position="Position.Right" />
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
31
src/components/workflow/ScrapeFileAction.vue
Normal file
31
src/components/workflow/ScrapeFileAction.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
|
||||
defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard max-width="20rem">
|
||||
<Handle id="edge_in" type="target" :position="Position.Left" />
|
||||
<VCardItem>
|
||||
<template v-slot:prepend>
|
||||
<VAvatar>
|
||||
<VIcon icon="mdi-file-find" size="x-large"></VIcon>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>刮削文件</VCardTitle>
|
||||
<VCardSubtitle>刮削媒体信息和图片</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<Handle id="edge_out" type="source" :position="Position.Right" />
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
31
src/components/workflow/SendEventAction.vue
Normal file
31
src/components/workflow/SendEventAction.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
|
||||
defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard max-width="20rem">
|
||||
<Handle id="edge_in" type="target" :position="Position.Left" />
|
||||
<VCardItem>
|
||||
<template v-slot:prepend>
|
||||
<VAvatar>
|
||||
<VIcon icon="mdi-send-check" size="x-large"></VIcon>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>发送事件</VCardTitle>
|
||||
<VCardSubtitle>发送任务执行事件</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<Handle id="edge_out" type="source" :position="Position.Right" />
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
80
src/components/workflow/SendMessageAction.vue
Normal file
80
src/components/workflow/SendMessageAction.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { NotificationConf } from '@/api/types'
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
|
||||
defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 所有消息渠道
|
||||
const notifications = ref<NotificationConf[]>([])
|
||||
|
||||
// 调用API查询通知渠道设置
|
||||
async function loadNotificationSetting() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Notifications')
|
||||
notifications.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算消息渠道选项
|
||||
const sourceOptions = computed(() => {
|
||||
return notifications.value.map(item => {
|
||||
return {
|
||||
title: item.name,
|
||||
value: item.name,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadNotificationSetting()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard max-width="20rem">
|
||||
<Handle id="edge_in" type="target" :position="Position.Left" />
|
||||
<VCardItem>
|
||||
<template v-slot:prepend>
|
||||
<VAvatar>
|
||||
<VIcon icon="mdi-message-arrow-right" size="x-large"></VIcon>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>发送消息</VCardTitle>
|
||||
<VCardSubtitle>发送任务执行消息</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VSelect
|
||||
v-model="data.client"
|
||||
:items="sourceOptions"
|
||||
label="消息渠道"
|
||||
chips
|
||||
multiple
|
||||
outlined
|
||||
dense
|
||||
clearable
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="data.userid" label="用户ID" chips multiple outlined dense clearable />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<Handle id="edge_out" type="source" :position="Position.Right" />
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
51
src/components/workflow/TransferFileAction.vue
Normal file
51
src/components/workflow/TransferFileAction.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
|
||||
defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 来源下拉框
|
||||
const sourceOptions = ref([
|
||||
{
|
||||
title: '文件列表',
|
||||
value: 'files',
|
||||
},
|
||||
{
|
||||
title: '下载任务',
|
||||
value: 'downloads',
|
||||
},
|
||||
])
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard max-width="20rem">
|
||||
<Handle id="edge_in" type="target" :position="Position.Left" />
|
||||
<VCardItem>
|
||||
<template v-slot:prepend>
|
||||
<VAvatar>
|
||||
<VIcon icon="mdi-file-move" size="x-large"></VIcon>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>整理文件</VCardTitle>
|
||||
<VCardSubtitle>整理重命名队列中的文件</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VSelect v-model="data.source" label="来源" :items="sourceOptions" outlined dense />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<Handle id="edge_out" type="source" :position="Position.Right" />
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -8,7 +8,7 @@ import UserNofification from '@/layouts/components/UserNotification.vue'
|
||||
import SearchBar from '@/layouts/components/SearchBar.vue'
|
||||
import ShortcutBar from '@/layouts/components/ShortcutBar.vue'
|
||||
import UserProfile from '@/layouts/components/UserProfile.vue'
|
||||
import store from '@/store'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { SystemNavMenus } from '@/router/menu'
|
||||
import { NavMenu } from '@/@layouts/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
@@ -16,8 +16,11 @@ import { useDisplay } from 'vuetify'
|
||||
const display = useDisplay()
|
||||
const appMode = inject('pwaMode')
|
||||
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 是否超级用户
|
||||
let superUser = store.state.auth.superUser
|
||||
let superUser = userStore.superUser
|
||||
|
||||
// 开始菜单项
|
||||
const startMenus = ref<NavMenu[]>([])
|
||||
@@ -64,7 +67,7 @@ onMounted(() => {
|
||||
<VIcon icon="mdi-menu" />
|
||||
</IconBtn>
|
||||
<!-- 👉 Back Button -->
|
||||
<IconBtn v-if="appMode && display.mdAndDown.value" class="ms-n2" @click="goBack">
|
||||
<IconBtn v-if="appMode" class="ms-n2" @click="goBack">
|
||||
<VIcon icon="mdi-arrow-left" size="32" />
|
||||
</IconBtn>
|
||||
<!-- 👉 Search Bar -->
|
||||
|
||||
12
src/layouts/components/DropzoneBackground.vue
Normal file
12
src/layouts/components/DropzoneBackground.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
import { Background } from '@vue-flow/background'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dropzone-background">
|
||||
<Background :size="2" :gap="20" pattern-color="#BDBDBD" />
|
||||
<div class="overlay">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,38 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { SystemNavMenus } from '@/router/menu'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { VMenu } from 'vuetify/lib/components/index.mjs'
|
||||
|
||||
const display = useDisplay()
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// 各按钮活动状态
|
||||
const moreMenuDialog = ref(false)
|
||||
|
||||
const moreMemus = computed(() => SystemNavMenus.filter(menu => !menu.footer))
|
||||
|
||||
const activeState = computed(() => {
|
||||
return {
|
||||
home: route.path === '/dashboard',
|
||||
ranking: route.path === '/ranking',
|
||||
recommend: route.path === '/recommend',
|
||||
movie: route.path === '/subscribe/movie',
|
||||
tv: route.path === '/subscribe/tv',
|
||||
apps: route.path === '/apps',
|
||||
}
|
||||
})
|
||||
|
||||
const moreActiveState = computed(() => {
|
||||
return !Object.values(activeState.value).some(v => v)
|
||||
})
|
||||
|
||||
const currentPath = computed(() => route.path)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="appMode" class="w-100" style="block-size: calc(3.5rem + env(safe-area-inset-bottom))">
|
||||
<div v-if="appMode" class="w-100">
|
||||
<VBottomNavigation
|
||||
grow
|
||||
horizontal
|
||||
color="primary"
|
||||
class="footer-nav border-t"
|
||||
style="block-size: calc(3.5rem + env(safe-area-inset-bottom))"
|
||||
:z-index="9998"
|
||||
>
|
||||
<VBtn to="/dashboard" :ripple="false">
|
||||
<VIcon v-if="activeState.home" size="28">mdi-home</VIcon>
|
||||
<VIcon v-else size="28">mdi-home-outline</VIcon>
|
||||
</VBtn>
|
||||
<VBtn to="/ranking" :ripple="false">
|
||||
<VIcon v-if="activeState.ranking" size="28">mdi-star</VIcon>
|
||||
<VBtn to="/recommend" :ripple="false">
|
||||
<VIcon v-if="activeState.recommend" size="28">mdi-star</VIcon>
|
||||
<VIcon v-else size="28">mdi-star-outline</VIcon>
|
||||
</VBtn>
|
||||
<VBtn to="/subscribe/movie" :ripple="false">
|
||||
@@ -43,9 +54,31 @@ const activeState = computed(() => {
|
||||
<VIcon v-if="activeState.tv" size="28">mdi-television-play</VIcon>
|
||||
<VIcon v-else size="28">mdi-television</VIcon>
|
||||
</VBtn>
|
||||
<VBtn to="/apps" :ripple="false">
|
||||
<VIcon v-if="activeState.apps" size="28">mdi-dots-horizontal-circle</VIcon>
|
||||
<VIcon v-else size="28">mdi-dots-horizontal</VIcon>
|
||||
<VBtn :ripple="false">
|
||||
<VIcon
|
||||
size="28"
|
||||
:icon="moreMenuDialog ? 'mdi-close' : 'mdi-dots-horizontal'"
|
||||
:color="moreActiveState ? 'primary' : ''"
|
||||
/>
|
||||
<VMenu v-model="moreMenuDialog" close-on-content-click activator="parent">
|
||||
<VDivider />
|
||||
<VList class="font-bold" lines="one">
|
||||
<VListSubheader class="bg-transparent"> 更多 </VListSubheader>
|
||||
<VListItem
|
||||
class="pe-20"
|
||||
v-for="(menu, index) in moreMemus"
|
||||
:key="index"
|
||||
:prepend-icon="menu.icon"
|
||||
nav
|
||||
:to="menu.to"
|
||||
:base-color="currentPath === menu.to ? 'primary' : undefined"
|
||||
>
|
||||
<VListItemTitle>
|
||||
<span class="text-lg">{{ menu.title }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</VBtn>
|
||||
</VBottomNavigation>
|
||||
</div>
|
||||
@@ -64,4 +97,3 @@ const activeState = computed(() => {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { useStore } from 'vuex'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import router from '@/router'
|
||||
@@ -7,9 +6,12 @@ import avatar1 from '@images/avatars/avatar-1.png'
|
||||
import api from '@/api'
|
||||
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||
import UserAuthDialog from '@/components/dialog/UserAuthDialog.vue'
|
||||
import { useAuthStore, useUserStore } from '@/stores'
|
||||
|
||||
// Vuex Store
|
||||
const store = useStore()
|
||||
// 认证 Store
|
||||
const authStore = useAuthStore()
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
@@ -29,7 +31,7 @@ const restartDialog = ref(false)
|
||||
// 执行注销操作
|
||||
function logout() {
|
||||
// 清除登录状态信息
|
||||
store.dispatch('auth/logout')
|
||||
authStore.logout()
|
||||
// 重定向到登录页面或其他适当的页面
|
||||
router.push('/login')
|
||||
}
|
||||
@@ -74,11 +76,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>
|
||||
@@ -113,6 +115,13 @@ const userLevel = computed(() => store.state.auth.level)
|
||||
<VListItemTitle>个人信息</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem link @click="router.push('/apps')">
|
||||
<template #prepend>
|
||||
<VIcon class="me-2" icon="mdi-view-grid-outline" size="22" />
|
||||
</template>
|
||||
<VListItemTitle>功能视图</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<!-- 👉 Site Auth -->
|
||||
<VListItem v-if="userLevel < 2 && superUser" link @click="showSiteAuthDialog">
|
||||
<template #prepend>
|
||||
@@ -156,7 +165,7 @@ const userLevel = computed(() => store.state.auth.level)
|
||||
<!-- 用户认证对话框 -->
|
||||
<UserAuthDialog v-if="siteAuthDialog" v-model="siteAuthDialog" @done="siteAuthDone" @close="siteAuthDialog = false" />
|
||||
<!-- 重启确认对话框 -->
|
||||
<VDialog v-model="restartDialog" max-width="25rem">
|
||||
<VDialog v-if="restartDialog" v-model="restartDialog" max-width="25rem">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<div class="flex items-center justify-center mt-3">
|
||||
|
||||
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>
|
||||
23
src/main.ts
23
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'
|
||||
@@ -17,7 +17,6 @@ import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
|
||||
import { CronVuetify } from '@vue-js-cron/vuetify'
|
||||
|
||||
// 4. 工具函数和其他辅助模块
|
||||
import { removeEl } from './@core/utils/dom'
|
||||
import { fetchGlobalSettings } from './api'
|
||||
import { isPWA } from './@core/utils/navigator'
|
||||
|
||||
@@ -56,9 +55,6 @@ async function initializeApp() {
|
||||
// 是否为PWA
|
||||
const pwaMode = await isPWA()
|
||||
app.provide('pwaMode', pwaMode)
|
||||
// 全局设置
|
||||
const globalSettings = await fetchGlobalSettings()
|
||||
app.provide('globalSettings', globalSettings)
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize app', error)
|
||||
}
|
||||
@@ -66,10 +62,16 @@ async function initializeApp() {
|
||||
|
||||
// 注册全局组件
|
||||
initializeApp().then(() => {
|
||||
// 优先注册框架
|
||||
// 1. 注册 UI 框架
|
||||
app.use(vuetify)
|
||||
|
||||
// 注册全局组件
|
||||
// 2. 注册状态管理与路由
|
||||
app.use(pinia).use(router)
|
||||
|
||||
// 3. 全局设置
|
||||
app.provide('globalSettings', fetchGlobalSettings())
|
||||
|
||||
// 4. 注册全局组件
|
||||
app
|
||||
.component('VAceEditor', VAceEditor)
|
||||
.component('VApexChart', VueApexCharts)
|
||||
@@ -85,10 +87,8 @@ initializeApp().then(() => {
|
||||
.component('VCronField', CronField)
|
||||
.component('VPathField', PathField)
|
||||
|
||||
// 注册插件
|
||||
// 5. 注册其他插件
|
||||
app
|
||||
.use(router)
|
||||
.use(store)
|
||||
.use(PerfectScrollbarPlugin)
|
||||
.use(ToastPlugin, {
|
||||
position: 'bottom-right',
|
||||
@@ -96,7 +96,7 @@ initializeApp().then(() => {
|
||||
.use(VuetifyUseDialog, {
|
||||
confirmDialog: {
|
||||
dialogProps: {
|
||||
maxWidth: '40rem',
|
||||
maxWidth: '30rem',
|
||||
},
|
||||
confirmationButtonProps: {
|
||||
variant: 'elevated',
|
||||
@@ -114,5 +114,4 @@ initializeApp().then(() => {
|
||||
},
|
||||
})
|
||||
.mount('#app')
|
||||
.$nextTick(() => removeEl('#loading-bg'))
|
||||
})
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<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[]>([])
|
||||
|
||||
// 根据分类获取菜单列表
|
||||
const getMenuList = () => {
|
||||
return SystemNavMenus.filter((item: NavMenu) => (!item.admin || superUser) && !item.footer)
|
||||
return SystemNavMenus.filter((item: NavMenu) => !item.admin || superUser)
|
||||
}
|
||||
|
||||
// APP列表
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import MediaCardListView from '@/views/discover/MediaCardListView.vue'
|
||||
import PersonCardListView from '@/views/discover/PersonCardListView.vue'
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -340,7 +340,7 @@ onDeactivated(() => {
|
||||
/>
|
||||
|
||||
<!-- 弹窗,根据配置生成选项 -->
|
||||
<VDialog v-model="dialog" max-width="35rem" scrollable>
|
||||
<VDialog v-if="dialog" v-model="dialog" max-width="35rem" scrollable>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>设置仪表板</VCardTitle>
|
||||
|
||||
90
src/pages/discover.vue
Normal file
90
src/pages/discover.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { DiscoverTabs } from '@/router/menu'
|
||||
import router from '@/router'
|
||||
import TheMovieDbView from '@/views/discover/TheMovieDbView.vue'
|
||||
import DoubanView from '@/views/discover/DoubanView.vue'
|
||||
import BangumiView from '@/views/discover/BangumiView.vue'
|
||||
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)
|
||||
|
||||
function jumpTab(tab: string) {
|
||||
router.push('/subscribe/discover?tab=' + tab)
|
||||
}
|
||||
|
||||
// 额外的数据源
|
||||
const extraDiscoverSources = ref<DiscoverSource[]>([])
|
||||
|
||||
// 加载额外的发现数据源
|
||||
async function loadExtraDiscoverSources() {
|
||||
try {
|
||||
extraDiscoverSources.value = await api.get('discover/source')
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadExtraDiscoverSources()
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
loadExtraDiscoverSources()
|
||||
})
|
||||
</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>
|
||||
|
||||
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
|
||||
<VWindowItem value="themoviedb">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<TheMovieDbView />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="douban">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<DoubanView />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="bangumi">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<BangumiView />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
<VWindowItem v-for="item in extraDiscoverSources" :key="item.mediaid_prefix" :value="item.mediaid_prefix">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<ExtraSourceView :source="item" />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
</div>
|
||||
</template>
|
||||
@@ -29,13 +29,17 @@ function jumpTab(tab: string) {
|
||||
onMounted(async () => {
|
||||
await loadDownloaderSetting()
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
loadDownloaderSetting()
|
||||
})
|
||||
</script>
|
||||
|
||||
<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="mx-5">{{ item.name }}</span>
|
||||
<span class="min-w-24">{{ item.name }}</span>
|
||||
</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'
|
||||
@@ -9,11 +10,14 @@ import logo from '@images/logo.png'
|
||||
import { useTheme } from 'vuetify'
|
||||
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({
|
||||
@@ -85,8 +89,7 @@ async function setTheme() {
|
||||
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
|
||||
// 存储主题到本地
|
||||
localStorage.setItem('theme', themeValue)
|
||||
localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background)
|
||||
saveLocalTheme(themeValue, globalTheme)
|
||||
}
|
||||
|
||||
// 订阅推送通知
|
||||
@@ -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) {
|
||||
|
||||
@@ -9,13 +9,16 @@ const mediaid = route.query?.mediaid?.toString()
|
||||
|
||||
// 类型
|
||||
const type = route.query?.type?.toString()
|
||||
|
||||
// 标题
|
||||
const title = route.query?.title?.toString()
|
||||
|
||||
// 年份
|
||||
const year = route.query?.year?.toString()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<MediaDetailView
|
||||
:mediaid="mediaid"
|
||||
:type="type"
|
||||
/>
|
||||
<MediaDetailView :mediaid="mediaid" :type="type" :title="title" :year="year" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
|
||||
|
||||
const viewList = reactive<{ apipath: string; linkurl: string; title: string }[]>([
|
||||
{
|
||||
apipath: 'tmdb/trending',
|
||||
linkurl: '/browse/tmdb/trending?title=流行趋势',
|
||||
title: '流行趋势',
|
||||
},
|
||||
{
|
||||
apipath: 'douban/showing',
|
||||
linkurl: '/browse/douban/showing?title=正在热映',
|
||||
title: '正在热映',
|
||||
},
|
||||
{
|
||||
apipath: 'bangumi/calendar',
|
||||
linkurl: '/browse/bangumi/calendar?title=Bangumi每日放送',
|
||||
title: 'Bangumi每日放送',
|
||||
},
|
||||
{
|
||||
apipath: 'tmdb/movies',
|
||||
linkurl: '/browse/tmdb/movies?title=TMDB热门电影',
|
||||
title: 'TMDB热门电影',
|
||||
},
|
||||
{
|
||||
apipath: 'tmdb/tvs?with_original_language=zh|en|ja|ko',
|
||||
linkurl: '/browse/tmdb/tvs??with_original_language=zh|en|ja|ko&title=TMDB热门电视剧',
|
||||
title: 'TMDB热门电视剧',
|
||||
},
|
||||
{
|
||||
apipath: 'douban/movie_hot',
|
||||
linkurl: '/browse/douban/movie_hot?title=豆瓣热门电影',
|
||||
title: '豆瓣热门电影',
|
||||
},
|
||||
{
|
||||
apipath: 'douban/tv_hot',
|
||||
linkurl: '/browse/douban/tv_hot?title=豆瓣热门电视剧',
|
||||
title: '豆瓣热门电视剧',
|
||||
},
|
||||
{
|
||||
apipath: 'douban/tv_animation',
|
||||
linkurl: '/browse/douban/tv_animation?title=豆瓣热门动漫',
|
||||
title: '豆瓣热门动漫',
|
||||
},
|
||||
{
|
||||
apipath: 'douban/movies',
|
||||
linkurl: '/browse/douban/movies?title=豆瓣最新电影',
|
||||
title: '豆瓣最新电影',
|
||||
},
|
||||
{
|
||||
apipath: 'douban/tvs',
|
||||
linkurl: '/browse/douban/tvs?title=豆瓣最新电视剧',
|
||||
title: '豆瓣最新电视剧',
|
||||
},
|
||||
{
|
||||
apipath: 'douban/movie_top250',
|
||||
linkurl: '/browse/douban/movie_top250?title=电影TOP250',
|
||||
title: '豆瓣电影TOP250',
|
||||
},
|
||||
{
|
||||
apipath: 'douban/tv_weekly_chinese',
|
||||
linkurl: '/browse/douban/tv_weekly_chinese?title=豆瓣国产剧集榜',
|
||||
title: '豆瓣国产剧集榜',
|
||||
},
|
||||
{
|
||||
apipath: 'douban/tv_weekly_global',
|
||||
linkurl: '/browse/douban/tv_weekly_global?title=豆瓣全球剧集榜',
|
||||
title: '豆瓣全球剧集榜',
|
||||
},
|
||||
])
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<MediaCardSlideView v-for="(item, index) in viewList" :key="index" v-bind="item" />
|
||||
</div>
|
||||
</template>
|
||||
196
src/pages/recommend.vue
Normal file
196
src/pages/recommend.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { RecommendSource } from '@/api/types'
|
||||
import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// APP
|
||||
const display = useDisplay()
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
|
||||
const viewList = reactive<{ apipath: string; linkurl: string; title: string }[]>([
|
||||
{
|
||||
apipath: 'recommend/tmdb_trending',
|
||||
linkurl: '/browse/recommend/tmdb_trending?title=流行趋势',
|
||||
title: '流行趋势',
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/douban_showing',
|
||||
linkurl: '/browse/recommend/douban_showing?title=正在热映',
|
||||
title: '正在热映',
|
||||
},
|
||||
{
|
||||
apipath: 'bangumi/calendar',
|
||||
linkurl: '/browse/bangumi/calendar?title=Bangumi每日放送',
|
||||
title: 'Bangumi每日放送',
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/tmdb_movies',
|
||||
linkurl: '/browse/recommend/tmdb_movies?title=TMDB热门电影',
|
||||
title: 'TMDB热门电影',
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/tmdb_tvs?with_original_language=zh|en|ja|ko',
|
||||
linkurl: '/browse/recommend/tmdb_tvs??with_original_language=zh|en|ja|ko&title=TMDB热门电视剧',
|
||||
title: 'TMDB热门电视剧',
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/douban_movie_hot',
|
||||
linkurl: '/browse/recommend/douban_movie_hot?title=豆瓣热门电影',
|
||||
title: '豆瓣热门电影',
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/douban_tv_hot',
|
||||
linkurl: '/browse/recommend/douban_tv_hot?title=豆瓣热门电视剧',
|
||||
title: '豆瓣热门电视剧',
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/douban_tv_animation',
|
||||
linkurl: '/browse/recommend/douban_tv_animation?title=豆瓣热门动漫',
|
||||
title: '豆瓣热门动漫',
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/douban_movies',
|
||||
linkurl: '/browse/recommend/douban_movies?title=豆瓣最新电影',
|
||||
title: '豆瓣最新电影',
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/douban_tvs',
|
||||
linkurl: '/browse/recommend/douban_tvs?title=豆瓣最新电视剧',
|
||||
title: '豆瓣最新电视剧',
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/douban_movie_top250',
|
||||
linkurl: '/browse/recommend/douban_movie_top250?title=电影TOP250',
|
||||
title: '豆瓣电影TOP250',
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/douban_tv_weekly_chinese',
|
||||
linkurl: '/browse/recommend/douban_tv_weekly_chinese?title=豆瓣国产剧集榜',
|
||||
title: '豆瓣国产剧集榜',
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/douban_tv_weekly_global',
|
||||
linkurl: '/browse/recommend/douban_tv_weekly_global?title=豆瓣全球剧集榜',
|
||||
title: '豆瓣全球剧集榜',
|
||||
},
|
||||
])
|
||||
|
||||
// 计算启用的视图
|
||||
const enabledViews = computed(() => viewList.filter(item => enableConfig.value[item.title]))
|
||||
|
||||
// 榜单启用配置, 以title为key
|
||||
const enableConfig = ref<{ [key: string]: boolean }>({
|
||||
...Object.fromEntries(viewList.map(item => [item.title, true])),
|
||||
})
|
||||
|
||||
// 弹窗
|
||||
const dialog = ref(false)
|
||||
|
||||
// 额外的数据源
|
||||
const extraRecommendSources = ref<RecommendSource[]>([])
|
||||
|
||||
// 加载额外的发现数据源
|
||||
async function loadExtraRecommendSources() {
|
||||
try {
|
||||
extraRecommendSources.value = await api.get('recommend/source')
|
||||
if (extraRecommendSources.value.length > 0) {
|
||||
viewList.push(
|
||||
...extraRecommendSources.value.map(source => ({
|
||||
apipath: source.api_path,
|
||||
linkurl: `/browse/recommend/${source.api_path}?title=${source.name}`,
|
||||
title: source.name,
|
||||
})),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载面板配置
|
||||
async function loadConfig() {
|
||||
// 显示配置
|
||||
const local_enable = localStorage.getItem('MP_RECOMMEND')
|
||||
if (local_enable) {
|
||||
enableConfig.value = JSON.parse(local_enable)
|
||||
} else {
|
||||
const response = await api.get('/user/config/Recommend')
|
||||
if (response && response.data && response.data.value) {
|
||||
enableConfig.value = response.data.value
|
||||
localStorage.setItem('MP_RECOMMEND', JSON.stringify(response.data.value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 设置项目
|
||||
async function saveConfig() {
|
||||
// 启用配置
|
||||
const enableString = JSON.stringify(enableConfig.value)
|
||||
localStorage.setItem('MP_RECOMMEND', enableString)
|
||||
|
||||
// 保存到服务端
|
||||
try {
|
||||
await api.post('/user/config/Recommend', enableConfig.value)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
dialog.value = false
|
||||
}
|
||||
|
||||
onBeforeMount(async () => {
|
||||
await loadConfig()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadExtraRecommendSources()
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
loadExtraRecommendSources()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<MediaCardSlideView v-for="item in enabledViews" :key="item.title" v-bind="item" />
|
||||
<!-- 弹窗,根据配置生成选项 -->
|
||||
<VDialog v-if="dialog" v-model="dialog" max-width="35rem" scrollable>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>设置推荐榜单</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol v-for="item in viewList" :key="item.title" cols="6" md="4" sm="4">
|
||||
<VCheckbox v-model="enableConfig[item.title]" :label="item.title" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VDivider />
|
||||
<VCardText class="pt-5 text-end">
|
||||
<VSpacer />
|
||||
<VBtn variant="outlined" color="secondary" class="me-4" @click="dialog = false"> 关闭 </VBtn>
|
||||
<VBtn @click="saveConfig">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-content-save" />
|
||||
</template>
|
||||
保存
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
<!-- 底部操作按钮 -->
|
||||
<VFab
|
||||
icon="mdi-text-box-edit"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="dialog = true"
|
||||
:class="{ 'mb-12': appMode }"
|
||||
/>
|
||||
</template>
|
||||
@@ -2,8 +2,8 @@
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import api from '@/api'
|
||||
import type { Context } from '@/api/types'
|
||||
import TorrentCardListView from '@/views/discover/TorrentCardListView.vue'
|
||||
import TorrentRowListView from '@/views/discover/TorrentRowListView.vue'
|
||||
import TorrentCardListView from '@/views/torrent/TorrentCardListView.vue'
|
||||
import TorrentRowListView from '@/views/torrent/TorrentRowListView.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// APP
|
||||
@@ -22,9 +22,18 @@ const type = route.query?.type?.toString() ?? ''
|
||||
// 搜索字段
|
||||
const area = route.query?.area?.toString() ?? ''
|
||||
|
||||
// 搜索标题
|
||||
const title = route.query?.title?.toString() ?? ''
|
||||
|
||||
// 搜索年份
|
||||
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')
|
||||
|
||||
@@ -82,13 +91,16 @@ async function fetchData() {
|
||||
} else {
|
||||
startLoadingProgress()
|
||||
let result: { [key: string]: any }
|
||||
// 优先按TMDBID精确查询
|
||||
if (keyword?.startsWith('tmdb:') || keyword?.startsWith('douban:') || keyword?.startsWith('bangumi:')) {
|
||||
// 如果keyword的格式是 xxxx:xxxxx 且:前面的xxxx为字符,则按照媒体ID格式搜索
|
||||
if (/^[a-zA-Z]+:/.test(keyword)) {
|
||||
result = await api.get(`search/media/${keyword}`, {
|
||||
params: {
|
||||
mtype: type,
|
||||
area,
|
||||
title,
|
||||
year,
|
||||
season,
|
||||
sites,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
@@ -96,6 +108,7 @@ async function fetchData() {
|
||||
result = await api.get(`search/title`, {
|
||||
params: {
|
||||
keyword,
|
||||
sites,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -139,27 +152,28 @@ onUnmounted(() => {
|
||||
<TorrentCardListView v-else :items="dataList" />
|
||||
</div>
|
||||
<!-- 视图切换 -->
|
||||
<VFab
|
||||
v-if="viewType === 'list'"
|
||||
class="mb-12"
|
||||
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 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>
|
||||
</template>
|
||||
|
||||
@@ -18,12 +18,18 @@ function jumpTab(tab: string) {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VTabs v-model="activeTab">
|
||||
<VTabs v-model="activeTab" show-arrows>
|
||||
<VTab v-if="subType == '电影'" v-for="item in SubscribeMovieTabs" :value="item.tab" @to="jumpTab(item.tab)">
|
||||
<span class="mx-5">{{ item.title }}</span>
|
||||
<div class="flex align-center min-w-24">
|
||||
<VIcon size="20" start :icon="item.icon" />
|
||||
{{ item.title }}
|
||||
</div>
|
||||
</VTab>
|
||||
<VTab v-if="subType == '电视剧'" v-for="item in SubscribeTvTabs" :value="item.tab" @to="jumpTab(item.tab)">
|
||||
<span class="mx-5">{{ item.title }}</span>
|
||||
<div class="flex align-center min-w-24">
|
||||
<VIcon size="20" start :icon="item.icon" />
|
||||
{{ item.title }}
|
||||
</div>
|
||||
</VTab>
|
||||
</VTabs>
|
||||
|
||||
|
||||
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>
|
||||
@@ -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()
|
||||
@@ -28,8 +28,16 @@ const router = createRouter({
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/ranking',
|
||||
component: () => import('../pages/ranking.vue'),
|
||||
path: '/recommend',
|
||||
component: () => import('../pages/recommend.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/discover',
|
||||
component: () => import('../pages/discover.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
@@ -60,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'),
|
||||
@@ -207,9 +223,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 {
|
||||
|
||||
@@ -8,20 +8,28 @@ export const SystemNavMenus = [
|
||||
admin: false,
|
||||
footer: true,
|
||||
},
|
||||
{
|
||||
title: '搜索结果',
|
||||
icon: 'mdi-magnify',
|
||||
to: '/resource',
|
||||
header: '开始',
|
||||
admin: false,
|
||||
},
|
||||
{
|
||||
title: '推荐',
|
||||
icon: 'mdi-star-outline',
|
||||
to: '/ranking',
|
||||
to: '/recommend',
|
||||
header: '发现',
|
||||
admin: false,
|
||||
footer: true,
|
||||
},
|
||||
{
|
||||
title: '资源搜索',
|
||||
icon: 'mdi-magnify',
|
||||
to: '/resource',
|
||||
title: '探索',
|
||||
icon: 'mdi-apple-safari',
|
||||
to: '/discover',
|
||||
header: '发现',
|
||||
admin: false,
|
||||
footer: false,
|
||||
},
|
||||
{
|
||||
title: '电影',
|
||||
@@ -41,6 +49,16 @@ export const SystemNavMenus = [
|
||||
admin: false,
|
||||
footer: true,
|
||||
},
|
||||
|
||||
{
|
||||
title: '工作流',
|
||||
full_title: '工作流',
|
||||
icon: 'mdi-state-machine',
|
||||
to: '/workflow',
|
||||
header: '订阅',
|
||||
admin: false,
|
||||
footer: false,
|
||||
},
|
||||
{
|
||||
title: '日历',
|
||||
full_title: '订阅日历',
|
||||
@@ -169,12 +187,12 @@ export const SubscribeMovieTabs = [
|
||||
{
|
||||
title: '我的订阅',
|
||||
tab: 'mysub',
|
||||
icon: 'mdi-movie-open-outline',
|
||||
icon: 'mdi-heart',
|
||||
},
|
||||
{
|
||||
title: '热门订阅',
|
||||
tab: 'popular',
|
||||
icon: 'mdi-movie-open-outline',
|
||||
icon: 'mdi-fire',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -183,17 +201,17 @@ export const SubscribeTvTabs = [
|
||||
{
|
||||
title: '我的订阅',
|
||||
tab: 'mysub',
|
||||
icon: 'mdi-television',
|
||||
icon: 'mdi-heart',
|
||||
},
|
||||
{
|
||||
title: '热门订阅',
|
||||
tab: 'popular',
|
||||
icon: 'mdi-television',
|
||||
icon: 'mdi-fire',
|
||||
},
|
||||
{
|
||||
title: '订阅分享',
|
||||
tab: 'share',
|
||||
icon: 'mdi-television',
|
||||
icon: 'mdi-share-variant',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -210,3 +228,22 @@ export const PluginTabs = [
|
||||
icon: 'mdi-store',
|
||||
},
|
||||
]
|
||||
|
||||
// 发现标签页
|
||||
export const DiscoverTabs = [
|
||||
{
|
||||
title: 'TheMovieDb',
|
||||
tab: 'themoviedb',
|
||||
icon: 'themoviedb',
|
||||
},
|
||||
{
|
||||
title: '豆瓣',
|
||||
tab: 'douban',
|
||||
icon: 'douban',
|
||||
},
|
||||
{
|
||||
title: '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,19 +3,45 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
|
||||
html.v-overlay-scroll-blocked {
|
||||
position: relative;
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
html.v-overlay-scroll-blocked body {
|
||||
--v-body-scroll-y: 0px !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px){
|
||||
html.v-overlay-scroll-blocked {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin hide-scrollbar {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
html,body {
|
||||
@include hide-scrollbar;
|
||||
}
|
||||
}
|
||||
|
||||
#nprogress .bar {
|
||||
background: rgb(var(--v-theme-primary)) !important;
|
||||
inset-block-start: env(safe-area-inset-top) !important;
|
||||
}
|
||||
|
||||
#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, -1px);
|
||||
transform: rotate(0deg) translate(0, 0px);
|
||||
}
|
||||
|
||||
.v-toast--bottom {
|
||||
@@ -32,6 +58,10 @@ html.v-overlay-scroll-blocked {
|
||||
margin-block: env(safe-area-inset-top) env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.v-bottom-sheet > .v-bottom-sheet__content.v-overlay__content > .v-card {
|
||||
padding-block-end: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.v-dialog--fullscreen > .v-overlay__content > .v-card {
|
||||
padding-block-end: calc(env(safe-area-inset-top) + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
107
src/views/discover/BangumiView.vue
Normal file
107
src/views/discover/BangumiView.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import MediaCardListView from '@/views/discover/MediaCardListView.vue'
|
||||
|
||||
// 过滤参数
|
||||
const filterParams = reactive({
|
||||
'type': 2,
|
||||
'cat': null,
|
||||
'sort': 'rank', // date/rank
|
||||
'year': null,
|
||||
})
|
||||
|
||||
// Bangumi cat字典
|
||||
/**
|
||||
* 0 为 其他
|
||||
1 为 TV
|
||||
2 为 OVA
|
||||
3 为 Movie
|
||||
5 为 WEB
|
||||
*/
|
||||
const bangumiCatDict = {
|
||||
'0': '其他',
|
||||
'1': 'TV',
|
||||
'2': 'OVA',
|
||||
'3': 'Movie',
|
||||
'5': 'WEB',
|
||||
}
|
||||
|
||||
// Bangumi排序字典
|
||||
const bangumiSortDict = {
|
||||
'rank': '排名',
|
||||
'date': '日期',
|
||||
}
|
||||
|
||||
// 年份字典,自动生成最近10年
|
||||
const yearDict: Record<number, number> = {}
|
||||
const currentYear = new Date().getFullYear()
|
||||
for (let i = 0; i < 10; i++) {
|
||||
yearDict[currentYear - i] = currentYear - i
|
||||
}
|
||||
|
||||
// 当前Key
|
||||
const currentKey = ref(0)
|
||||
|
||||
// 类型和过滤参数变化后重新刷新列表
|
||||
watch([filterParams], () => {
|
||||
currentKey.value++
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-3">
|
||||
<div class="flex justify-start align-center">
|
||||
<div class="mr-5">
|
||||
<VLabel>类别</VLabel>
|
||||
</div>
|
||||
<VChipGroup v-model="filterParams.cat">
|
||||
<VChip
|
||||
:color="filterParams.cat == key ? 'primary' : ''"
|
||||
filter
|
||||
tile
|
||||
:value="key"
|
||||
v-for="(value, key) in bangumiCatDict"
|
||||
:key="key"
|
||||
>
|
||||
{{ value }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</div>
|
||||
<div class="flex justify-start align-center">
|
||||
<div class="mr-5">
|
||||
<VLabel>排序</VLabel>
|
||||
</div>
|
||||
<VChipGroup v-model="filterParams.sort">
|
||||
<VChip
|
||||
:color="filterParams.sort == key ? 'primary' : ''"
|
||||
filter
|
||||
tile
|
||||
:value="key"
|
||||
v-for="(value, key) in bangumiSortDict"
|
||||
:key="key"
|
||||
>
|
||||
{{ value }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</div>
|
||||
<div class="flex justify-start align-center">
|
||||
<div class="mr-5">
|
||||
<VLabel>年份</VLabel>
|
||||
</div>
|
||||
<VChipGroup v-model="filterParams.year">
|
||||
<VChip
|
||||
:color="filterParams.year == key ? 'primary' : ''"
|
||||
filter
|
||||
tile
|
||||
:value="key"
|
||||
v-for="(value, key) in yearDict"
|
||||
:key="key"
|
||||
>
|
||||
{{ value }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<MediaCardListView :key="currentKey" apipath="discover/bangumi" :params="filterParams" />
|
||||
</div>
|
||||
</template>
|
||||
202
src/views/discover/DoubanView.vue
Normal file
202
src/views/discover/DoubanView.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<script setup lang="ts">
|
||||
import MediaCardListView from '@/views/discover/MediaCardListView.vue'
|
||||
|
||||
// 电影或者电视剧 movies/tvs
|
||||
const type = ref('movies')
|
||||
|
||||
// 过滤参数
|
||||
const filterParams = reactive({
|
||||
'sort': 'U',
|
||||
'tags': '',
|
||||
})
|
||||
|
||||
// 豆瓣风格类型
|
||||
const doubanCategory = ref('')
|
||||
|
||||
// 地区
|
||||
const doubanZone = ref('')
|
||||
|
||||
// 年代
|
||||
const doubanYear = ref('')
|
||||
|
||||
// 豆瓣风格字典
|
||||
const categoryDict = {
|
||||
'喜剧': '喜剧',
|
||||
'爱情': '爱情',
|
||||
'动作': '动作',
|
||||
'科幻': '科幻',
|
||||
'动画': '动画',
|
||||
'悬疑': '悬疑',
|
||||
'犯罪': '犯罪',
|
||||
'惊悚': '惊悚',
|
||||
'冒险': '冒险',
|
||||
'音乐': '音乐',
|
||||
'历史': '历史',
|
||||
'奇幻': '奇幻',
|
||||
'恐怖': '恐怖',
|
||||
'战争': '战争',
|
||||
'传记': '传记',
|
||||
'歌舞': '歌舞',
|
||||
'武侠': '武侠',
|
||||
'情色': '情色',
|
||||
'灾难': '灾难',
|
||||
'西部': '西部',
|
||||
'纪录片': '纪录片',
|
||||
'短片': '短片',
|
||||
}
|
||||
|
||||
// 地区字典
|
||||
const zoneDict = {
|
||||
'华语': '华语',
|
||||
'欧美': '欧美',
|
||||
'韩国': '韩国',
|
||||
'日本': '日本',
|
||||
'中国大陆': '中国大陆',
|
||||
'美国': '美国',
|
||||
'中国香港': '中国香港',
|
||||
'中国台湾': '中国台湾',
|
||||
'英国': '英国',
|
||||
'法国': '法国',
|
||||
'德国': '德国',
|
||||
'意大利': '意大利',
|
||||
'西班牙': '西班牙',
|
||||
'印度': '印度',
|
||||
'泰国': '泰国',
|
||||
'俄罗斯': '俄罗斯',
|
||||
'加拿大': '加拿大',
|
||||
'澳大利亚': '澳大利亚',
|
||||
'爱尔兰': '爱尔兰',
|
||||
'瑞典': '瑞典',
|
||||
'巴西': '巴西',
|
||||
'丹麦': '丹麦',
|
||||
}
|
||||
|
||||
// 年代字典
|
||||
const yearDict: Record<string, string> = {
|
||||
'2020年代': '2020年代',
|
||||
'2010年代': '2010年代',
|
||||
'2000年代': '2000年代',
|
||||
'90年代': '90年代',
|
||||
'80年代': '80年代',
|
||||
'70年代': '70年代',
|
||||
'60年代': '60年代',
|
||||
}
|
||||
|
||||
// 往年代字典中追加当前年份及往前5年的字典
|
||||
const currentYear = new Date().getFullYear()
|
||||
for (let i = 0; i < 6; i++) {
|
||||
yearDict[`${currentYear - i}`] = `${currentYear - i}`
|
||||
}
|
||||
|
||||
// 豆瓣过滤参数
|
||||
const doubanSortDict = {
|
||||
'U': '综合排序',
|
||||
'R': '首播时间',
|
||||
'T': '近期热度',
|
||||
'S': '高分优先',
|
||||
}
|
||||
|
||||
// 风格、年代、地区变化时,以,分隔拼接到tags参数
|
||||
watch([doubanCategory, doubanZone, doubanYear], () => {
|
||||
filterParams.tags = [doubanCategory.value, doubanZone.value, doubanYear.value].filter(Boolean).join(',')
|
||||
})
|
||||
|
||||
// 当前Key
|
||||
const currentKey = ref(0)
|
||||
|
||||
// 类型和过滤参数变化后重新刷新列表
|
||||
watch([type, filterParams], () => {
|
||||
if (!type.value) {
|
||||
type.value = 'movies'
|
||||
}
|
||||
if (!filterParams.sort) {
|
||||
filterParams.sort = 'U'
|
||||
}
|
||||
currentKey.value++
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-3">
|
||||
<div class="flex justify-start align-center">
|
||||
<div class="mr-5">
|
||||
<VLabel>类型</VLabel>
|
||||
</div>
|
||||
<VChipGroup v-model="type">
|
||||
<VChip :color="type == 'movies' ? 'primary' : ''" filter tile value="movies">电影</VChip>
|
||||
<VChip :color="type == 'tvs' ? 'primary' : ''" filter tile value="tvs">电视剧</VChip>
|
||||
</VChipGroup>
|
||||
</div>
|
||||
<div class="flex justify-start align-center">
|
||||
<div class="mr-5">
|
||||
<VLabel>排序</VLabel>
|
||||
</div>
|
||||
<VChipGroup v-model="filterParams.sort">
|
||||
<VChip
|
||||
:color="filterParams.sort == key ? 'primary' : ''"
|
||||
filter
|
||||
tile
|
||||
:value="key"
|
||||
v-for="(value, key) in doubanSortDict"
|
||||
:key="key"
|
||||
>
|
||||
{{ value }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</div>
|
||||
<div class="flex justify-start align-center">
|
||||
<div class="mr-5">
|
||||
<VLabel>风格</VLabel>
|
||||
</div>
|
||||
<VChipGroup v-model="doubanCategory">
|
||||
<VChip
|
||||
:color="doubanCategory == key ? 'primary' : ''"
|
||||
filter
|
||||
tile
|
||||
:value="key"
|
||||
v-for="(value, key) in categoryDict"
|
||||
:key="key"
|
||||
>
|
||||
{{ value }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</div>
|
||||
<div class="flex justify-start align-center">
|
||||
<div class="mr-5">
|
||||
<VLabel>地区</VLabel>
|
||||
</div>
|
||||
<VChipGroup v-model="doubanZone">
|
||||
<VChip
|
||||
:color="doubanZone == key ? 'primary' : ''"
|
||||
filter
|
||||
tile
|
||||
:value="key"
|
||||
v-for="(value, key) in zoneDict"
|
||||
:key="key"
|
||||
>
|
||||
{{ value }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</div>
|
||||
<div class="flex justify-start align-center">
|
||||
<div class="mr-5">
|
||||
<VLabel>年代</VLabel>
|
||||
</div>
|
||||
<VChipGroup v-model="doubanYear">
|
||||
<VChip
|
||||
:color="doubanYear == key ? 'primary' : ''"
|
||||
filter
|
||||
tile
|
||||
:value="key"
|
||||
v-for="(value, key) in yearDict"
|
||||
:key="key"
|
||||
>
|
||||
{{ value }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<MediaCardListView :key="currentKey" :apipath="`discover/douban_${type}`" :params="filterParams" />
|
||||
</div>
|
||||
</template>
|
||||
64
src/views/discover/ExtraSourceView.vue
Normal file
64
src/views/discover/ExtraSourceView.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { DiscoverSource } from '@/api/types'
|
||||
import MediaCardListView from '@/views/discover/MediaCardListView.vue'
|
||||
import FormRender from '@/components/render/FormRender.vue'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps<{
|
||||
source: DiscoverSource
|
||||
}>()
|
||||
|
||||
// 默认输入参数
|
||||
const default_params = cloneDeep(props.source.filter_params)
|
||||
|
||||
// 过滤参数
|
||||
const filterParams = reactive(props.source.filter_params)
|
||||
|
||||
// 前一次的过滤参数
|
||||
let previousParams = cloneDeep(props.source.filter_params)
|
||||
|
||||
// 当前Key
|
||||
const currentKey = ref(0)
|
||||
|
||||
// 类型和过滤参数变化后重新刷新列表
|
||||
watch(filterParams, newParams => {
|
||||
// 检查每个值
|
||||
for (const key in newParams) {
|
||||
// 如果没有值但有默认值时,设置为默认值
|
||||
if (!newParams[key] && default_params[key]) {
|
||||
filterParams[key] = default_params[key]
|
||||
}
|
||||
// 检查依赖关系
|
||||
const depends = props.source?.depends
|
||||
if (depends) {
|
||||
if (newParams[key] !== previousParams[key]) {
|
||||
for (const dependKey in depends) {
|
||||
if (key != dependKey && depends[dependKey] && depends[dependKey].includes(key)) {
|
||||
filterParams[dependKey] = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 更新 previousParams
|
||||
previousParams = cloneDeep(newParams)
|
||||
// 刷新界面
|
||||
currentKey.value++
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-3">
|
||||
<FormRender v-for="(element, index) in source.filter_ui" :key="index" :config="element" :model="filterParams" />
|
||||
</div>
|
||||
<div>
|
||||
<MediaCardListView :key="currentKey" :apipath="source.api_path" :params="filterParams" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.v-chip--selected {
|
||||
color: rgb(var(--v-theme-primary)) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -27,6 +27,7 @@ const isRefreshed = ref(false)
|
||||
// 数据列表
|
||||
const dataList = ref<MediaInfo[]>([])
|
||||
const currData = ref<MediaInfo[]>([])
|
||||
|
||||
// 拼装参数
|
||||
function getParams() {
|
||||
let params = {
|
||||
@@ -77,7 +78,6 @@ async function fetchData({ done }: { done: any }) {
|
||||
} else {
|
||||
// 加载一次
|
||||
// 设置加载中
|
||||
|
||||
loading.value = true
|
||||
// 请求API
|
||||
currData.value = await api.get(props.apipath, {
|
||||
@@ -115,7 +115,11 @@ async function fetchData({ done }: { done: any }) {
|
||||
<div v-if="dataList.length > 0" class="grid gap-4 grid-media-card mx-3" tabindex="0">
|
||||
<MediaCard v-for="data in dataList" :key="data.tmdb_id || data.douban_id" :media="data" />
|
||||
</div>
|
||||
<NoDataFound v-if="dataList.length === 0 && isRefreshed" error-code="404" error-title="没有数据"
|
||||
error-description="无法获取到媒体信息。" />
|
||||
<NoDataFound
|
||||
v-if="dataList.length === 0 && isRefreshed"
|
||||
error-code="404"
|
||||
error-title="没有数据"
|
||||
error-description="无法获取到媒体信息。"
|
||||
/>
|
||||
</VInfiniteScroll>
|
||||
</template>
|
||||
|
||||
@@ -3,24 +3,28 @@ import { useToast } from 'vue-toast-notification'
|
||||
import PersonCardSlideView from './PersonCardSlideView.vue'
|
||||
import MediaCardSlideView from './MediaCardSlideView.vue'
|
||||
import api from '@/api'
|
||||
import type { MediaInfo, NotExistMediaInfo, Subscribe, TmdbEpisode } from '@/api/types'
|
||||
import type { MediaInfo, NotExistMediaInfo, Site, Subscribe, TmdbEpisode } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import { formatSeason } from '@/@core/utils/formatters'
|
||||
import router from '@/router'
|
||||
import SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { useUserStore } from '@/stores'
|
||||
|
||||
// 输入参数
|
||||
const mediaProps = defineProps({
|
||||
mediaid: String,
|
||||
title: String,
|
||||
year: String,
|
||||
type: String,
|
||||
})
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettings: any = inject('globalSettings')
|
||||
|
||||
const store = useStore()
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
@@ -55,13 +59,44 @@ const seasonsSubscribed = ref<{ [key: number]: boolean }>({})
|
||||
// 订阅编号
|
||||
const subscribeId = ref<number>()
|
||||
|
||||
// 所有站点
|
||||
const allSites = ref<Site[]>([])
|
||||
|
||||
// 选中的站点
|
||||
const selectedSites = ref<number[]>([])
|
||||
|
||||
// 搜索方式 title/imdbid
|
||||
const searchType = ref('title')
|
||||
|
||||
// 查询所有站点
|
||||
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() {
|
||||
return mediaDetail.value?.tmdb_id
|
||||
? `tmdb:${mediaDetail.value?.tmdb_id}`
|
||||
: mediaDetail.value?.douban_id
|
||||
? `douban:${mediaDetail.value?.douban_id}`
|
||||
: `bangumi:${mediaDetail.value?.bangumi_id}`
|
||||
if (mediaDetail.value?.tmdb_id) return `tmdb:${mediaDetail.value?.tmdb_id}`
|
||||
else if (mediaDetail.value?.douban_id) return `douban:${mediaDetail.value?.douban_id}`
|
||||
else if (mediaDetail.value?.bangumi_id) return `bangumi:${mediaDetail.value?.bangumi_id}`
|
||||
else return `${mediaDetail.value?.mediaid_prefix}:${mediaDetail.value?.media_id}`
|
||||
}
|
||||
|
||||
// 调用API查询详情
|
||||
@@ -69,6 +104,8 @@ async function getMediaDetail() {
|
||||
if (mediaProps.mediaid && mediaProps.type) {
|
||||
mediaDetail.value = await api.get(`media/${mediaProps.mediaid}`, {
|
||||
params: {
|
||||
title: mediaProps.title,
|
||||
year: mediaProps.year,
|
||||
type_name: mediaProps.type,
|
||||
},
|
||||
})
|
||||
@@ -395,15 +432,18 @@ function joinArray(arr: string[]) {
|
||||
}
|
||||
|
||||
// 开始搜索
|
||||
function handleSearch(area: string) {
|
||||
function handleSearch() {
|
||||
const keyword = getMediaId()
|
||||
router.push({
|
||||
path: '/resource',
|
||||
query: {
|
||||
keyword,
|
||||
type: mediaDetail.value.type,
|
||||
area,
|
||||
area: searchType.value,
|
||||
title: mediaDetail.value.title,
|
||||
year: mediaDetail.value.year,
|
||||
season: mediaDetail.value.season,
|
||||
sites: selectedSites.value.join(','),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -428,7 +468,7 @@ async function handlePlay() {
|
||||
|
||||
async function queryDefaultSubscribeConfig() {
|
||||
// 非管理员不显示
|
||||
if (!store.state.auth.superUser) return false
|
||||
if (!userStore.superUser) return false
|
||||
try {
|
||||
let subscribe_config_url = ''
|
||||
if (mediaProps.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
||||
@@ -450,6 +490,13 @@ function onSubscribeEditRemove() {
|
||||
else checkSeasonsSubscribed()
|
||||
}
|
||||
|
||||
// 点击搜索
|
||||
async function clickSearch() {
|
||||
if (allSites.value?.length > 0) return
|
||||
querySites()
|
||||
querySelectedSites()
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
getMediaDetail()
|
||||
})
|
||||
@@ -507,38 +554,45 @@ onBeforeMount(() => {
|
||||
</div>
|
||||
<div class="media-actions">
|
||||
<VBtn
|
||||
v-if="(mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id) && mediaDetail.imdb_id"
|
||||
v-if="mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id"
|
||||
variant="tonal"
|
||||
color="info"
|
||||
class="mb-2"
|
||||
@click="clickSearch"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-magnify" />
|
||||
</template>
|
||||
搜索资源
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VMenu activator="parent" close-on-content-click max-width="450">
|
||||
<VList>
|
||||
<VListItem variant="plain" @click="handleSearch('title')">
|
||||
<VListItemTitle>标题</VListItemTitle>
|
||||
<VListItem>
|
||||
<VBtnToggle v-model="searchType" color="primary" @click.stop>
|
||||
<VBtn value="title">标题</VBtn>
|
||||
<VBtn value="imdbid" v-show="mediaDetail.imdb_id">IMDB链接</VBtn>
|
||||
</VBtnToggle>
|
||||
</VListItem>
|
||||
<VListItem v-show="mediaDetail.imdb_id" variant="plain" @click="handleSearch('imdbid')">
|
||||
<VListItemTitle>IMDB链接</VListItemTitle>
|
||||
<VListItem>
|
||||
<VChipGroup v-model="selectedSites" column multiple @click.stop>
|
||||
<VChip
|
||||
v-for="site in allSites"
|
||||
:key="site.id"
|
||||
:color="selectedSites.includes(site.id) ? 'primary' : ''"
|
||||
filter
|
||||
variant="outlined"
|
||||
:value="site.id"
|
||||
size="small"
|
||||
>
|
||||
{{ site.name }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</VListItem>
|
||||
<VListItem>
|
||||
<VBtn @click="handleSearch" block>搜索</VBtn>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="(mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id) && !mediaDetail.imdb_id"
|
||||
variant="tonal"
|
||||
color="info"
|
||||
class="mb-2"
|
||||
@click="handleSearch('title')"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-magnify" />
|
||||
</template>
|
||||
搜索资源
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="mediaDetail.type === '电影' || mediaDetail.douban_id || mediaDetail.bangumi_id"
|
||||
class="ms-2 mb-2"
|
||||
|
||||
219
src/views/discover/TheMovieDbView.vue
Normal file
219
src/views/discover/TheMovieDbView.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<script setup lang="ts">
|
||||
import MediaCardListView from '@/views/discover/MediaCardListView.vue'
|
||||
|
||||
// 电影或者电视剧 movies/tvs
|
||||
const type = ref('movies')
|
||||
|
||||
// 过滤参数
|
||||
const filterParams = reactive({
|
||||
sort_by: 'popularity.desc',
|
||||
with_genres: '',
|
||||
with_original_language: '',
|
||||
with_keywords: '',
|
||||
with_watch_providers: '',
|
||||
vote_average: 0,
|
||||
vote_count: 10,
|
||||
release_date: '',
|
||||
})
|
||||
|
||||
// TMDB 电影排序字典
|
||||
const tmdbSortDict: Record<string, string> = {
|
||||
'popularity.desc': '热度降序',
|
||||
'popularity.asc': '热度升序',
|
||||
'release_date.desc': '上映日期降序',
|
||||
'release_date.asc': '上映日期升序',
|
||||
'vote_average.desc': '评分降序',
|
||||
'vote_average.asc': '评分升序',
|
||||
}
|
||||
|
||||
// TMDB 电视剧排序字典
|
||||
const tmdbTvSortDict: Record<string, string> = {
|
||||
'popularity.desc': '热度降序',
|
||||
'popularity.asc': '热度升序',
|
||||
'first_air_date.desc': '首播日期降序',
|
||||
'first_air_date.asc': '首播日期升序',
|
||||
'vote_average.desc': '评分降序',
|
||||
'vote_average.asc': '评分升序',
|
||||
}
|
||||
|
||||
// TMDB电影风格字典
|
||||
const tmdbMovieGenreDict: Record<string, string> = {
|
||||
'28': '动作',
|
||||
'12': '冒险',
|
||||
'16': '动画',
|
||||
'35': '喜剧',
|
||||
'80': '犯罪',
|
||||
'99': '纪录片',
|
||||
'18': '剧情',
|
||||
'10751': '家庭',
|
||||
'14': '奇幻',
|
||||
'36': '历史',
|
||||
'27': '恐怖',
|
||||
'10402': '音乐',
|
||||
'9648': '悬疑',
|
||||
'10749': '爱情',
|
||||
'878': '科幻',
|
||||
'10770': '电视电影',
|
||||
'53': '惊悚',
|
||||
'10752': '战争',
|
||||
'37': '西部',
|
||||
}
|
||||
|
||||
// TMDB电视剧风格字典
|
||||
const tmdbTvGenreDict: Record<string, string> = {
|
||||
'10759': '动作冒险',
|
||||
'16': '动画',
|
||||
'35': '喜剧',
|
||||
'80': '犯罪',
|
||||
'99': '纪录片',
|
||||
'18': '剧情',
|
||||
'10751': '家庭',
|
||||
'10762': '儿童',
|
||||
'9648': '悬疑',
|
||||
'10763': '新闻',
|
||||
'10764': '真人秀',
|
||||
'10765': '科幻奇幻',
|
||||
'10766': '肥皂剧',
|
||||
'10767': '戏剧',
|
||||
'10768': '战争政治',
|
||||
'37': '西部',
|
||||
}
|
||||
|
||||
// TMDB原始语言字典(主要语言)
|
||||
const tmdbLanguageDict = {
|
||||
'zh': '中文',
|
||||
'en': '英语',
|
||||
'ja': '日语',
|
||||
'ko': '韩语',
|
||||
'fr': '法语',
|
||||
'de': '德语',
|
||||
'es': '西班牙语',
|
||||
'it': '意大利语',
|
||||
'ru': '俄语',
|
||||
'pt': '葡萄牙语',
|
||||
'ar': '阿拉伯语',
|
||||
'hi': '印地语',
|
||||
'th': '泰语',
|
||||
}
|
||||
|
||||
// 当前Key
|
||||
const currentKey = ref(0)
|
||||
|
||||
// 类型变化
|
||||
watch(type, () => {
|
||||
if (!type.value) {
|
||||
type.value = 'movies'
|
||||
}
|
||||
if (type.value === 'movies') {
|
||||
if (!tmdbSortDict[filterParams.sort_by]) {
|
||||
filterParams.sort_by = 'popularity.desc'
|
||||
}
|
||||
if (!tmdbMovieGenreDict[filterParams.with_genres]) {
|
||||
filterParams.with_genres = ''
|
||||
}
|
||||
}
|
||||
if (type.value === 'tvs') {
|
||||
if (!tmdbTvSortDict[filterParams.sort_by]) {
|
||||
filterParams.sort_by = 'popularity.desc'
|
||||
}
|
||||
if (!tmdbTvGenreDict[filterParams.with_genres]) {
|
||||
filterParams.with_genres = ''
|
||||
}
|
||||
}
|
||||
currentKey.value++
|
||||
})
|
||||
|
||||
// 过滤参数变化
|
||||
watch(filterParams, () => {
|
||||
if (!filterParams.sort_by) {
|
||||
filterParams.sort_by = 'popularity.desc'
|
||||
}
|
||||
currentKey.value++
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-3">
|
||||
<div class="flex justify-start align-center">
|
||||
<div class="mr-5">
|
||||
<VLabel>类型</VLabel>
|
||||
</div>
|
||||
<VChipGroup v-model="type">
|
||||
<VChip :color="type == 'movies' ? 'primary' : ''" filter tile value="movies">电影</VChip>
|
||||
<VChip :color="type == 'tvs' ? 'primary' : ''" filter tile value="tvs">电视剧</VChip>
|
||||
</VChipGroup>
|
||||
</div>
|
||||
<div class="flex justify-start align-center">
|
||||
<div class="mr-5">
|
||||
<VLabel>排序</VLabel>
|
||||
</div>
|
||||
<VChipGroup v-model="filterParams.sort_by">
|
||||
<VChip
|
||||
:color="filterParams.sort_by == key ? 'primary' : ''"
|
||||
filter
|
||||
tile
|
||||
:value="key"
|
||||
v-for="(value, key) in type == 'movies' ? tmdbSortDict : tmdbTvSortDict"
|
||||
:key="key"
|
||||
>
|
||||
{{ value }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</div>
|
||||
<div class="flex justify-start align-center">
|
||||
<div class="mr-5">
|
||||
<VLabel>风格</VLabel>
|
||||
</div>
|
||||
<VChipGroup v-model="filterParams.with_genres">
|
||||
<VChip
|
||||
:color="filterParams.with_genres == key ? 'primary' : ''"
|
||||
filter
|
||||
tile
|
||||
:value="key"
|
||||
v-for="(value, key) in type == 'movies' ? tmdbMovieGenreDict : tmdbTvGenreDict"
|
||||
:key="key"
|
||||
>
|
||||
{{ value }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</div>
|
||||
<div class="flex justify-start align-center">
|
||||
<div class="mr-5">
|
||||
<VLabel>语言</VLabel>
|
||||
</div>
|
||||
<VChipGroup v-model="filterParams.with_original_language">
|
||||
<VChip
|
||||
:color="filterParams.with_original_language == key ? 'primary' : ''"
|
||||
filter
|
||||
tile
|
||||
:value="key"
|
||||
v-for="(value, key) in tmdbLanguageDict"
|
||||
:key="key"
|
||||
>
|
||||
{{ value }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</div>
|
||||
<div class="flex justify-start align-center">
|
||||
<div class="mr-5">
|
||||
<VLabel>评分</VLabel>
|
||||
</div>
|
||||
<VSlider v-model="filterParams.vote_average" thumb-label max="10" min="0" class="align-center" hide-details>
|
||||
<template v-slot:append>
|
||||
<VTextField
|
||||
width="5rem"
|
||||
v-model="filterParams.vote_count"
|
||||
density="compact"
|
||||
type="number"
|
||||
hide-details
|
||||
single-line
|
||||
/>
|
||||
</template>
|
||||
</VSlider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MediaCardListView :key="currentKey" :apipath="`discover/tmdb_${type}`" :params="filterParams" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,391 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Context } from '@/api/types'
|
||||
import TorrentItem from '@/components/cards/TorrentItem.vue'
|
||||
import { list } from 'postcss'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// APP
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
|
||||
// 定义输入参数
|
||||
const props = defineProps({
|
||||
// 数据列表
|
||||
items: Array as PropType<Context[]>,
|
||||
})
|
||||
|
||||
// 过滤表单
|
||||
const filterForm = reactive({
|
||||
// 站点
|
||||
site: [] as string[],
|
||||
// 季
|
||||
season: [] as string[],
|
||||
// 制作组
|
||||
releaseGroup: [] as string[],
|
||||
// 视频编码
|
||||
videoCode: [] as string[],
|
||||
// 促销状态
|
||||
freeState: [] as string[],
|
||||
// 质量
|
||||
edition: [] as string[],
|
||||
// 分辨率
|
||||
resolution: [] as string[],
|
||||
})
|
||||
|
||||
// 列表样式
|
||||
const listStyle = computed(() => {
|
||||
return appMode
|
||||
? 'height: calc(100vh - 7.5rem - env(safe-area-inset-bottom) - 3.5rem)'
|
||||
: 'height: calc(100vh - 6.5rem - env(safe-area-inset-bottom)'
|
||||
})
|
||||
|
||||
// 排序字段
|
||||
const sortField = ref('default')
|
||||
|
||||
// 数据列表
|
||||
const dataList = ref<Array<Context>>([])
|
||||
|
||||
// 获取站点过滤选项
|
||||
const siteFilterOptions = ref<Array<string>>([])
|
||||
// 获取季过滤选项
|
||||
const seasonFilterOptions = ref<Array<string>>([])
|
||||
// 获取制作组过滤选项
|
||||
const releaseGroupFilterOptions = ref<Array<string>>([])
|
||||
// 获取视频编码过滤选项
|
||||
const videoCodeFilterOptions = ref<Array<string>>([])
|
||||
// 获取促销状态过滤选项
|
||||
const freeStateFilterOptions = ref<Array<string>>([])
|
||||
// 获取质量过滤选项
|
||||
const editionFilterOptions = ref<Array<string>>([])
|
||||
// 获取分辨率过滤选项
|
||||
const resolutionFilterOptions = ref<Array<string>>([])
|
||||
|
||||
// 初始化过滤选项
|
||||
function initOptions(data: Context) {
|
||||
const { torrent_info, meta_info } = data
|
||||
const optionValue = (options: Array<string>, value: string | undefined) => {
|
||||
value && !options.includes(value) && options.push(value)
|
||||
}
|
||||
optionValue(siteFilterOptions.value, torrent_info?.site_name)
|
||||
optionValue(seasonFilterOptions.value, meta_info?.season_episode)
|
||||
optionValue(releaseGroupFilterOptions.value, meta_info?.resource_team)
|
||||
optionValue(videoCodeFilterOptions.value, meta_info?.video_encode)
|
||||
optionValue(freeStateFilterOptions.value, torrent_info?.volume_factor)
|
||||
optionValue(editionFilterOptions.value, meta_info?.edition)
|
||||
optionValue(resolutionFilterOptions.value, meta_info?.resource_pix)
|
||||
}
|
||||
|
||||
// 对季过滤选项进行排序
|
||||
const sortSeasonFilterOptions = computed(() => {
|
||||
// 预解析所有选项
|
||||
const parsedOptions = seasonFilterOptions.value.map((option, index) => {
|
||||
const parseSeasonEpisode = (str: string) => {
|
||||
const match = str.match(/^S(\d+)(?:-S(\d+))?(?:\s*E(\d+)(?:-E(\d+))?)?$/)
|
||||
|
||||
if (!match) {
|
||||
// 如果字符串格式不正确,返回默认值
|
||||
return {
|
||||
original: str,
|
||||
seasonStart: 0,
|
||||
seasonEnd: 0,
|
||||
episodeStart: 0,
|
||||
episodeEnd: 0,
|
||||
maxSeason: 0,
|
||||
maxEpisode: 0,
|
||||
index,
|
||||
}
|
||||
}
|
||||
|
||||
const seasonStart = match[1] ? parseInt(match[1], 10) : 0
|
||||
const seasonEnd = match[2] ? parseInt(match[2], 10) : 0
|
||||
const episodeStart = match[3] ? parseInt(match[3], 10) : 0
|
||||
const episodeEnd = match[4] ? parseInt(match[4], 10) : 0
|
||||
const maxSeason = seasonEnd > 0 ? seasonEnd : seasonStart
|
||||
const maxEpisode = episodeEnd > 0 ? episodeEnd : episodeStart
|
||||
|
||||
return {
|
||||
original: str,
|
||||
seasonStart,
|
||||
seasonEnd,
|
||||
episodeStart,
|
||||
episodeEnd,
|
||||
maxSeason,
|
||||
maxEpisode,
|
||||
index,
|
||||
}
|
||||
}
|
||||
|
||||
return parseSeasonEpisode(option)
|
||||
})
|
||||
|
||||
// 定义判断是否为整季或季范围的函数
|
||||
const isWholeSeason = (parsed: (typeof parsedOptions)[0]) =>
|
||||
parsed.seasonStart > 0 &&
|
||||
(parsed.seasonEnd === 0 || parsed.seasonEnd > parsed.seasonStart) &&
|
||||
parsed.episodeStart === 0 &&
|
||||
parsed.episodeEnd === 0
|
||||
|
||||
// 定义判断是否包含集数的函数
|
||||
const hasEpisodes = (parsed: (typeof parsedOptions)[0]) => parsed.episodeStart > 0 || parsed.episodeEnd > 0
|
||||
|
||||
// 排序逻辑
|
||||
parsedOptions.sort((a, b) => {
|
||||
const aIsWhole = isWholeSeason(a)
|
||||
const bIsWhole = isWholeSeason(b)
|
||||
const aHasEpisodes = hasEpisodes(a)
|
||||
const bHasEpisodes = hasEpisodes(b)
|
||||
|
||||
// 优先级1:整季和季范围选项优先于带有集数的选项
|
||||
if (aIsWhole && !bIsWhole) return -1
|
||||
if (!aIsWhole && bIsWhole) return 1
|
||||
|
||||
// 优先级2:如果都是整季或季范围选项,按 maxSeason 降序排列
|
||||
if (aIsWhole && bIsWhole) {
|
||||
if (b.maxSeason !== a.maxSeason) {
|
||||
return b.maxSeason - a.maxSeason
|
||||
}
|
||||
// 如果 maxSeason 相同,则按原始索引
|
||||
return a.index - b.index
|
||||
}
|
||||
|
||||
// 优先级3:如果都是带有集数的选项,先按 maxSeason 降序,再按 maxEpisode 降序
|
||||
if (aHasEpisodes && bHasEpisodes) {
|
||||
if (b.maxSeason !== a.maxSeason) {
|
||||
return b.maxSeason - a.maxSeason
|
||||
}
|
||||
if (b.maxEpisode !== a.maxEpisode) {
|
||||
return b.maxEpisode - a.maxEpisode
|
||||
}
|
||||
// 如果 maxSeason 和 maxEpisode 相同,则按原始索引
|
||||
return a.index - b.index
|
||||
}
|
||||
|
||||
// 优先级4:如果一个有集数,一个没有,优先有集数的选项
|
||||
if (aHasEpisodes && !bHasEpisodes) return -1
|
||||
if (!aHasEpisodes && bHasEpisodes) return 1
|
||||
|
||||
// 优先级5:对于没有集数且不是整季的选项,按 seasonStart 和 seasonEnd 降序排序
|
||||
if (b.seasonStart !== a.seasonStart) {
|
||||
return b.seasonStart - a.seasonStart
|
||||
}
|
||||
if (b.seasonEnd !== a.seasonEnd) {
|
||||
return b.seasonEnd - a.seasonEnd
|
||||
}
|
||||
|
||||
// 优先级6:按 episodeStart 和 episodeEnd 降序排序
|
||||
if (b.episodeStart !== a.episodeStart) {
|
||||
return b.episodeStart - a.episodeStart
|
||||
}
|
||||
if (b.episodeEnd !== a.episodeEnd) {
|
||||
return b.episodeEnd - a.episodeEnd
|
||||
}
|
||||
|
||||
// 优先级7:兜底按字母降序排列
|
||||
if (a.original !== b.original) {
|
||||
return b.original.localeCompare(a.original)
|
||||
}
|
||||
|
||||
// 优先级8:如果所有条件都相同,则按原始索引
|
||||
return a.index - b.index
|
||||
})
|
||||
|
||||
// 返回排序后的原始字符串数组
|
||||
return parsedOptions.map(option => option.original)
|
||||
})
|
||||
|
||||
// 排序
|
||||
watchEffect(() => {
|
||||
const list = dataList.value
|
||||
if (sortField.value === 'default') {
|
||||
dataList.value = list.sort((a, b) => b.torrent_info.pri_order - a.torrent_info.pri_order)
|
||||
} else if (sortField.value === 'site') {
|
||||
dataList.value = list.sort((a, b) => (a.torrent_info.site_name || '').localeCompare(b.torrent_info.site_name || ''))
|
||||
} else if (sortField.value === 'size') {
|
||||
dataList.value = list.sort((a, b) => b.torrent_info.size - a.torrent_info.size)
|
||||
} else if (sortField.value === 'seeder') {
|
||||
dataList.value = list.sort((a, b) => b.torrent_info.seeders - a.torrent_info.seeders)
|
||||
}
|
||||
})
|
||||
|
||||
// 计算过滤后的列表
|
||||
watchEffect(() => {
|
||||
// 清空列表
|
||||
dataList.value = []
|
||||
// 匹配过滤函数
|
||||
const match = (filter: Array<string>, value: string | undefined) =>
|
||||
filter.length === 0 || (value && filter.includes(value))
|
||||
|
||||
props.items?.forEach(data => {
|
||||
const { meta_info, torrent_info } = data
|
||||
if (
|
||||
// 站点过滤
|
||||
match(filterForm.site, torrent_info.site_name) &&
|
||||
// 促销状态过滤
|
||||
match(filterForm.freeState, torrent_info.volume_factor) &&
|
||||
// 季过滤
|
||||
match(filterForm.season, meta_info.season_episode) &&
|
||||
// 制作组过滤
|
||||
match(filterForm.releaseGroup, meta_info.resource_team) &&
|
||||
// 视频编码过滤
|
||||
match(filterForm.videoCode, meta_info.video_encode) &&
|
||||
// 分辨率过滤
|
||||
match(filterForm.resolution, meta_info.resource_pix) &&
|
||||
// 质量过滤
|
||||
match(filterForm.edition, meta_info.edition)
|
||||
)
|
||||
dataList.value.push(data)
|
||||
})
|
||||
})
|
||||
|
||||
// 初始化过滤选项
|
||||
onMounted(() => {
|
||||
props.items?.forEach(item => {
|
||||
initOptions(item)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VRow>
|
||||
<VCol>
|
||||
<VList v-if="dataList.length === 0" lines="three" class="rounded p-0 shadow-lg">
|
||||
<VListItem>
|
||||
<VListItemTitle>没有附合当前过滤条件的资源。</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
<VList v-if="dataList.length !== 0" lines="three" class="rounded p-0 torrent-list-vscroll shadow-lg">
|
||||
<VVirtualScroll :items="dataList" :style="listStyle">
|
||||
<template #default="{ item }">
|
||||
<TorrentItem :torrent="item" :key="`${item.torrent_info.page_url}`" />
|
||||
</template>
|
||||
</VVirtualScroll>
|
||||
</VList>
|
||||
</VCol>
|
||||
<VCol xl="2" md="3" v-if="display.mdAndUp.value">
|
||||
<VList lines="one" class="rounded shadow-lg" :style="listStyle">
|
||||
<VListSubheader> 排序 </VListSubheader>
|
||||
<VListItem>
|
||||
<VChipGroup column v-model="sortField">
|
||||
<VChip :color="sortField == 'default' ? 'primary' : ''" filter variant="outlined" value="default">
|
||||
默认
|
||||
</VChip>
|
||||
<VChip :color="sortField == 'site' ? 'primary' : ''" filter variant="outlined" value="site"> 站点 </VChip>
|
||||
<VChip :color="sortField == 'size' ? 'primary' : ''" filter variant="outlined" value="size">
|
||||
文件大小
|
||||
</VChip>
|
||||
<VChip :color="sortField == 'seeder' ? 'primary' : ''" filter variant="outlined" value="seeder">
|
||||
做种数
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</VListItem>
|
||||
<VListSubheader v-if="siteFilterOptions.length > 0"> 站点 </VListSubheader>
|
||||
<VListItem>
|
||||
<VChipGroup v-model="filterForm.site" column multiple>
|
||||
<VChip
|
||||
v-for="site in siteFilterOptions"
|
||||
:key="site"
|
||||
:color="filterForm.site.includes(site) ? 'primary' : ''"
|
||||
filter
|
||||
variant="outlined"
|
||||
:value="site"
|
||||
>
|
||||
{{ site }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</VListItem>
|
||||
<VListSubheader v-if="editionFilterOptions.length > 0"> 质量 </VListSubheader>
|
||||
<VListItem>
|
||||
<VChipGroup v-model="filterForm.edition" column multiple>
|
||||
<VChip
|
||||
v-for="edition in editionFilterOptions"
|
||||
:key="edition"
|
||||
:color="filterForm.edition.includes(edition) ? 'primary' : ''"
|
||||
filter
|
||||
variant="outlined"
|
||||
:value="edition"
|
||||
>
|
||||
{{ edition }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</VListItem>
|
||||
<VListSubheader v-if="resolutionFilterOptions.length > 0"> 分辨率 </VListSubheader>
|
||||
<VListItem>
|
||||
<VChipGroup v-model="filterForm.resolution" column multiple>
|
||||
<VChip
|
||||
v-for="resolution in resolutionFilterOptions"
|
||||
:key="resolution"
|
||||
:color="filterForm.resolution.includes(resolution) ? 'primary' : ''"
|
||||
filter
|
||||
variant="outlined"
|
||||
:value="resolution"
|
||||
>
|
||||
{{ resolution }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</VListItem>
|
||||
<VListSubheader v-if="releaseGroupFilterOptions.length > 0"> 制作组 </VListSubheader>
|
||||
<VListItem>
|
||||
<VChipGroup v-model="filterForm.releaseGroup" column multiple>
|
||||
<VChip
|
||||
v-for="releaseGroup in releaseGroupFilterOptions"
|
||||
:key="releaseGroup"
|
||||
:color="filterForm.releaseGroup.includes(releaseGroup) ? 'primary' : ''"
|
||||
filter
|
||||
variant="outlined"
|
||||
:value="releaseGroup"
|
||||
>
|
||||
{{ releaseGroup }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</VListItem>
|
||||
<VListSubheader v-if="videoCodeFilterOptions.length > 0"> 视频编码 </VListSubheader>
|
||||
<VListItem>
|
||||
<VChipGroup v-model="filterForm.videoCode" column multiple>
|
||||
<VChip
|
||||
v-for="videoCode in videoCodeFilterOptions"
|
||||
:key="videoCode"
|
||||
:color="filterForm.videoCode.includes(videoCode) ? 'primary' : ''"
|
||||
filter
|
||||
variant="outlined"
|
||||
:value="videoCode"
|
||||
>
|
||||
{{ videoCode }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</VListItem>
|
||||
<VListSubheader v-if="freeStateFilterOptions.length > 0"> 促销状态 </VListSubheader>
|
||||
<VListItem>
|
||||
<VChipGroup v-model="filterForm.freeState" column multiple>
|
||||
<VChip
|
||||
v-for="freeState in freeStateFilterOptions"
|
||||
:key="freeState"
|
||||
:color="filterForm.freeState.includes(freeState) ? 'primary' : ''"
|
||||
filter
|
||||
variant="outlined"
|
||||
:value="freeState"
|
||||
>
|
||||
{{ freeState }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</VListItem>
|
||||
<VListSubheader v-if="seasonFilterOptions.length > 0"> 季集 </VListSubheader>
|
||||
<VListItem>
|
||||
<VChipGroup v-model="filterForm.season" column multiple>
|
||||
<VChip
|
||||
v-for="season in sortSeasonFilterOptions"
|
||||
:key="season"
|
||||
:color="filterForm.season.includes(season) ? 'primary' : ''"
|
||||
filter
|
||||
variant="outlined"
|
||||
:value="season"
|
||||
>
|
||||
{{ season }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</template>
|
||||
@@ -397,9 +397,12 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VTabs v-model="activeTab">
|
||||
<VTabs v-model="activeTab" show-arrows>
|
||||
<VTab v-for="item in PluginTabs" :value="item.tab">
|
||||
<span class="mx-5">{{ item.title }}</span>
|
||||
<div class="flex align-center min-w-24">
|
||||
<VIcon size="20" start :icon="item.icon" />
|
||||
{{ item.title }}
|
||||
</div>
|
||||
</VTab>
|
||||
</VTabs>
|
||||
|
||||
@@ -507,7 +510,7 @@ onMounted(async () => {
|
||||
</VWindow>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div v-if="isRefreshed">
|
||||
<!-- 插件搜索图标 -->
|
||||
<VFab
|
||||
icon="mdi-magnify"
|
||||
@@ -546,7 +549,6 @@ onMounted(async () => {
|
||||
v-if="SearchDialog"
|
||||
v-model="SearchDialog"
|
||||
scrollable
|
||||
:z-index="1010"
|
||||
max-width="40rem"
|
||||
:max-height="!display.mdAndUp.value ? '' : '85vh'"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
@@ -604,7 +606,7 @@ onMounted(async () => {
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 安装插件进度框 -->
|
||||
<VDialog v-model="progressDialog" :scrim="false" width="25rem">
|
||||
<VDialog v-if="progressDialog" v-model="progressDialog" :scrim="false" width="25rem">
|
||||
<VCard color="primary">
|
||||
<VCardText class="text-center">
|
||||
{{ progressText }}
|
||||
|
||||
@@ -4,13 +4,16 @@ import api from '@/api'
|
||||
import type { DownloadingInfo } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import DownloadingCard from '@/components/cards/DownloadingCard.vue'
|
||||
import store from '@/store'
|
||||
import { useUserStore } from '@/stores'
|
||||
|
||||
// 定义输入参数
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
}>()
|
||||
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 定时器
|
||||
let refreshTimer: NodeJS.Timeout | null = null
|
||||
|
||||
@@ -42,9 +45,9 @@ function onRefresh() {
|
||||
|
||||
// 过滤数据,管理员用户显示全部,非管理员只显示自己的订阅
|
||||
const filteredDataList = computed(() => {
|
||||
// 从Vuex Store中获取用户信息
|
||||
const superUser = store.state.auth.superUser
|
||||
const userName = store.state.auth.userName
|
||||
// 从 Store 中获取用户信息
|
||||
const superUser = userStore.superUser
|
||||
const userName = userStore.userName
|
||||
if (superUser) return dataList.value
|
||||
else return dataList.value.filter(data => data.userid === userName || data.username === userName)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { debounce } from 'lodash'
|
||||
import { debounce } from 'lodash-es'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import api from '@/api'
|
||||
import type { TransferHistory } from '@/api/types'
|
||||
@@ -48,7 +48,41 @@ const headers = [
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
title: '目录',
|
||||
title: '路径',
|
||||
key: 'src',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
title: '转移方式',
|
||||
key: 'mode',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
key: 'date',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
sortable: false,
|
||||
},
|
||||
]
|
||||
|
||||
// 分组表头
|
||||
const groupHeaders = [
|
||||
{
|
||||
title: '季集/类别',
|
||||
key: 'title',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
title: '路径',
|
||||
key: 'src',
|
||||
sortable: true,
|
||||
},
|
||||
@@ -98,6 +132,16 @@ const loading = ref(false)
|
||||
// 总条数
|
||||
const totalItems = ref(0)
|
||||
|
||||
// 是否要分组
|
||||
const group = ref(false)
|
||||
|
||||
// 分组条件
|
||||
const groupBy = ref<any>([
|
||||
{
|
||||
key: 'title',
|
||||
},
|
||||
])
|
||||
|
||||
// 每页条数
|
||||
const itemsPerPage = ref<number>(ensureNumber(route.query.itemsPerPage, 50))
|
||||
|
||||
@@ -113,6 +157,9 @@ const progressText = ref('请稍候 ...')
|
||||
// 进度值
|
||||
const progressValue = ref(0)
|
||||
|
||||
// 是否已刷新
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
// 删除确认对话框
|
||||
const deleteConfirmDialog = ref(false)
|
||||
|
||||
@@ -160,7 +207,8 @@ watch(
|
||||
)
|
||||
|
||||
// 搜索监听
|
||||
watch([() => search.value, () => isComposing.value],
|
||||
watch(
|
||||
[() => search.value, () => isComposing.value],
|
||||
debounce(async () => {
|
||||
if (!isComposing.value) {
|
||||
console.log('search: ' + search.value)
|
||||
@@ -181,7 +229,7 @@ async function fetchData(page = currentPage.value, count = itemsPerPage.value) {
|
||||
title: search.value,
|
||||
},
|
||||
})
|
||||
|
||||
isRefreshed.value = true
|
||||
dataList.value = result.data?.list
|
||||
totalItems.value = result.data?.total
|
||||
searchHintList.value = ['失败', '成功', ...new Set(dataList.value.map(item => item.title || ''))].filter(
|
||||
@@ -403,7 +451,107 @@ onMounted(fetchData)
|
||||
</VRow>
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<!-- 分组模式 -->
|
||||
<VDataTableVirtual
|
||||
v-if="group"
|
||||
v-model="selected"
|
||||
:groupBy="groupBy"
|
||||
:headers="groupHeaders"
|
||||
:items="dataList"
|
||||
:loading="loading"
|
||||
density="compact"
|
||||
return-object
|
||||
fixed-header
|
||||
show-select
|
||||
loading-text="加载中..."
|
||||
hover
|
||||
:style="tableStyle"
|
||||
>
|
||||
<template #header.data-table-group>
|
||||
<span>标题</span>
|
||||
</template>
|
||||
<template v-slot:group-header="{ item, columns, toggleGroup, isGroupOpen }">
|
||||
<tr>
|
||||
<td :colspan="columns.length">
|
||||
<VBtn
|
||||
:icon="isGroupOpen(item) ? '$expand' : '$next'"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="toggleGroup(item)"
|
||||
/>
|
||||
{{ item.value }}
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template #item.title="{ item }">
|
||||
<div class="d-flex align-center">
|
||||
<VAvatar>
|
||||
<VIcon :icon="getIcon(item.type || '')" />
|
||||
</VAvatar>
|
||||
<div class="d-flex flex-column ms-1">
|
||||
<span v-if="item.type === '电视剧'" class="d-block text-high-emphasis min-w-20">
|
||||
{{ item?.seasons }}{{ item?.episodes }}
|
||||
</span>
|
||||
<small>{{ item?.category }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #item.src="{ item }">
|
||||
<div>
|
||||
<span>
|
||||
<VChip variant="tonal" size="small" label class="my-1"> {{ storageDict[item?.src_storage || ''] }}</VChip>
|
||||
<small>{{ item?.src }}</small>
|
||||
</span>
|
||||
<span class="text-high-emphasis text-bold"> => </span>
|
||||
<br />
|
||||
<span v-if="item?.dest">
|
||||
<VChip variant="tonal" size="small" label class="my-1"> {{ storageDict[item?.dest_storage || ''] }}</VChip>
|
||||
<small>{{ item?.dest }}</small>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #item.mode="{ item }">
|
||||
<VChip variant="outlined" color="primary" size="small">
|
||||
{{ TransferDict[item?.mode ?? ''] || '未知' }}
|
||||
</VChip>
|
||||
</template>
|
||||
<template #item.status="{ item }">
|
||||
<VChip v-if="item?.status" color="success" size="small"> 成功 </VChip>
|
||||
<VTooltip v-else :text="item?.errmsg">
|
||||
<template #activator="{ props }">
|
||||
<VChip v-bind="props" color="error" size="small"> 失败 </VChip>
|
||||
</template>
|
||||
</VTooltip>
|
||||
</template>
|
||||
<template #item.date="{ item }">
|
||||
<small>{{ item?.date }}</small>
|
||||
</template>
|
||||
<template #item.actions="{ item }">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(menu, i) in dropdownItems"
|
||||
:key="i"
|
||||
variant="plain"
|
||||
:base-color="menu.props.color"
|
||||
@click="menu.props.click(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="menu.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</template>
|
||||
<template #no-data> 没有数据 </template>
|
||||
</VDataTableVirtual>
|
||||
<!-- 列表模式 -->
|
||||
<VDataTableVirtual
|
||||
v-else
|
||||
v-model="selected"
|
||||
:headers="headers"
|
||||
:items="dataList"
|
||||
@@ -485,15 +633,18 @@ onMounted(fetchData)
|
||||
</template>
|
||||
<template #no-data> 没有数据 </template>
|
||||
</VDataTableVirtual>
|
||||
<div class="flex items-center justify-end">
|
||||
<!-- 分页 -->
|
||||
<VDivider />
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="w-auto">
|
||||
<VSelect v-model="itemsPerPage" :items="pageRange" density="compact" variant="solo" flat />
|
||||
</div>
|
||||
<div class="w-auto text-sm">{{ pageTip.begin }}-{{ pageTip.end }} / {{ totalItems }}</div>
|
||||
<div class="w-auto text-sm">{{ pageTip.begin }} - {{ pageTip.end }} / {{ totalItems }}</div>
|
||||
<VPagination
|
||||
v-model="currentPage"
|
||||
show-first-last-page
|
||||
:length="totalPage"
|
||||
:total-visible="display.mdAndUp.value ? 7 : 0"
|
||||
@next="currentPage + 1"
|
||||
@prev="currentPage - 1"
|
||||
>
|
||||
@@ -502,9 +653,8 @@ onMounted(fetchData)
|
||||
</VCard>
|
||||
|
||||
<!-- 底部操作按钮 -->
|
||||
<div>
|
||||
<div v-if="isRefreshed && selected.length > 0">
|
||||
<VFab
|
||||
v-if="selected.length > 0"
|
||||
icon="mdi-trash-can-outline"
|
||||
color="error"
|
||||
location="bottom"
|
||||
@@ -516,7 +666,6 @@ onMounted(fetchData)
|
||||
:class="{ 'mb-12': appMode }"
|
||||
/>
|
||||
<VFab
|
||||
v-if="selected.length > 0"
|
||||
:class="appMode ? 'mb-28' : 'mb-16'"
|
||||
icon="mdi-redo-variant"
|
||||
location="bottom"
|
||||
@@ -527,6 +676,19 @@ onMounted(fetchData)
|
||||
@click="retransferBatch"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="isRefreshed">
|
||||
<VFab
|
||||
:icon="group ? 'mdi-format-list-bulleted' : 'mdi-format-list-group'"
|
||||
color="primary"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="group = !group"
|
||||
:class="{ 'mb-12': appMode }"
|
||||
/>
|
||||
</div>
|
||||
<!-- 底部弹窗 -->
|
||||
<VBottomSheet v-model="deleteConfirmDialog" inset>
|
||||
<VCard class="text-center rounded-t">
|
||||
|
||||
@@ -255,7 +255,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<VDialog v-model="releaseDialog" width="600" scrollable>
|
||||
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<DialogCloseBtn @click="releaseDialog = false" />
|
||||
|
||||
@@ -16,7 +16,7 @@ const customRules = ref<CustomRule[]>([])
|
||||
const filterRuleGroups = ref<FilterRuleGroup[]>([])
|
||||
|
||||
// 种子优先规则
|
||||
const selectedTorrentPriority = ref<string>('seeder')
|
||||
const selectedTorrentPriority = ref<string[]>(['seeder'])
|
||||
|
||||
// 二级分类策略
|
||||
const mediaCategories = ref<{ [key: string]: any }>({})
|
||||
@@ -240,32 +240,11 @@ function extractFilterRuleGroups(value: any) {
|
||||
// 根据ID简单区分规则与规则组
|
||||
function checkValueValidity(values: any, type: string): boolean {
|
||||
try {
|
||||
// 允许空值存在,不影响最终的导入
|
||||
if (!values) return true
|
||||
if (!type) return false
|
||||
for (const value of values) {
|
||||
const keys = Object.keys(value)
|
||||
const uniqueKeys = new Set(keys)
|
||||
|
||||
const hasName = keys.includes('name')
|
||||
const hasId = keys.includes('id')
|
||||
const noDuplicates = keys.length === uniqueKeys.size
|
||||
if (type == 'custom') {
|
||||
if (!hasName || !hasId || !noDuplicates) {
|
||||
if (!noDuplicates) $toast.warning(`存在重名值`)
|
||||
if (!hasId) $toast.error(`导入失败!发现有规则不存在ID,可能属于优先级规则组!`)
|
||||
return false
|
||||
}
|
||||
} else if (type == 'group') {
|
||||
if (!hasName || hasId || !noDuplicates) {
|
||||
if (!noDuplicates) $toast.warning(`存在重名值`)
|
||||
if (hasId) $toast.error(`导入失败!发现有规则存在ID,可能属于自定义规则!`)
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
console.error(`传入了不合法的类型!`)
|
||||
return false
|
||||
}
|
||||
for (const value of values) {
|
||||
if (!isValidValue(value, type)) return false
|
||||
}
|
||||
return true
|
||||
} catch (e) {
|
||||
@@ -274,6 +253,41 @@ function checkValueValidity(values: any, type: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function isValidValue(value: any, type: string): boolean {
|
||||
const keys = Object.keys(value)
|
||||
const uniqueKeys = new Set(keys)
|
||||
const hasName = keys.includes('name')
|
||||
const hasId = keys.includes('id')
|
||||
const noDuplicates = keys.length === uniqueKeys.size
|
||||
|
||||
if (type === 'custom') {
|
||||
return validateCustomRule(hasName, hasId, noDuplicates)
|
||||
} else if (type === 'group') {
|
||||
return validateGroupRule(hasName, hasId, noDuplicates)
|
||||
} else {
|
||||
console.error(`传入了不合法的类型!`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function validateCustomRule(hasName: boolean, hasId: boolean, noDuplicates: boolean): boolean {
|
||||
if (!hasName || !hasId || !noDuplicates) {
|
||||
if (!noDuplicates) $toast.warning(`存在重名值`)
|
||||
if (!hasId) $toast.error(`导入失败!发现有规则不存在ID,可能属于优先级规则组!`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function validateGroupRule(hasName: boolean, hasId: boolean, noDuplicates: boolean): boolean {
|
||||
if (!hasName || hasId || !noDuplicates) {
|
||||
if (!noDuplicates) $toast.warning(`存在重名值`)
|
||||
if (hasId) $toast.error(`导入失败!发现有规则存在相同ID,可能属于自定义规则!`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 清空规则(组)
|
||||
function deleteAllRules(dateType: string) {
|
||||
if (!dateType) return
|
||||
@@ -389,7 +403,7 @@ onMounted(() => {
|
||||
<VIcon icon="mdi-share" />
|
||||
</VBtn>
|
||||
<VBtn color="error" variant="tonal" @click="deleteAllRules('custom')">
|
||||
<VIcon icon="mdi-delete" />
|
||||
<VIcon icon="mdi-delete-empty-outline" />
|
||||
</VBtn>
|
||||
</VBtnGroup>
|
||||
</div>
|
||||
@@ -440,7 +454,7 @@ onMounted(() => {
|
||||
<VIcon icon="mdi-share" />
|
||||
</VBtn>
|
||||
<VBtn color="error" variant="tonal" @click="deleteAllRules('group')">
|
||||
<VIcon icon="mdi-delete" />
|
||||
<VIcon icon="mdi-delete-empty-outline" />
|
||||
</VBtn>
|
||||
</VBtnGroup>
|
||||
</div>
|
||||
|
||||
@@ -236,9 +236,9 @@ const wallpaperItems = [
|
||||
]
|
||||
|
||||
// 预设部分Github加速站
|
||||
const githubMirrorsItems = [
|
||||
'https://mirror.ghproxy.com/', // GitHub Proxy
|
||||
'https://ghp.ci/', // GitHub Proxy 子站
|
||||
const githubMirrorsItems: string[] = [
|
||||
// str: 'https://mirror.ghproxy.com/', // GitHub Proxy
|
||||
// str: 'https://ghp.ci/', // GitHub Proxy 子站
|
||||
]
|
||||
|
||||
// 预设部分PIP镜像站
|
||||
@@ -687,7 +687,7 @@ onDeactivated(() => {
|
||||
<VCombobox
|
||||
v-model="SystemSettings.Advanced.GITHUB_PROXY"
|
||||
label="Github加速代理"
|
||||
placeholder="https://mirror.ghproxy.com/"
|
||||
placeholder="http(s)://host/"
|
||||
hint="使用代理加速Github访问速度"
|
||||
persistent-hint
|
||||
:items="githubMirrorsItems"
|
||||
@@ -697,7 +697,7 @@ onDeactivated(() => {
|
||||
<VCombobox
|
||||
v-model="SystemSettings.Advanced.PIP_PROXY"
|
||||
label="PIP加速代理"
|
||||
placeholder="https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||
placeholder="http(s)://host"
|
||||
hint="使用代理加速插件等pip库安装速度"
|
||||
persistent-hint
|
||||
:items="pipMirrorsItems"
|
||||
|
||||
@@ -111,6 +111,7 @@ onActivated(() => {
|
||||
/>
|
||||
<!-- 新增站点按钮 -->
|
||||
<VFab
|
||||
v-if="isRefreshed"
|
||||
icon="mdi-plus"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import draggable from 'vuedraggable'
|
||||
import { VPullToRefresh } from 'vuetify/labs/VPullToRefresh'
|
||||
import api from '@/api'
|
||||
import type { Subscribe } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import SubscribeCard from '@/components/cards/SubscribeCard.vue'
|
||||
import SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue'
|
||||
import SubscribeHistoryDialog from '@/components/dialog/SubscribeHistoryDialog.vue'
|
||||
import store from '@/store'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// APP
|
||||
const display = useDisplay()
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
type: String,
|
||||
@@ -23,6 +25,13 @@ const props = defineProps({
|
||||
// 是否刷新过
|
||||
let isRefreshed = ref(false)
|
||||
|
||||
// 顺序存储键值
|
||||
const localOrderKey = props.type === '电影' ? 'MP_SUBSCRIBE_MOVIE_ORDER' : 'MP_SUBSCRIBE_TV_ORDER'
|
||||
const orderRequestKey = props.type === '电影' ? 'SubscribeMovieOrder' : 'SubscribeTvOrder'
|
||||
|
||||
// 刷新状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 数据列表
|
||||
const dataList = ref<Subscribe[]>([])
|
||||
|
||||
@@ -40,24 +49,26 @@ const displayList = ref<Subscribe[]>([])
|
||||
|
||||
// 监听dataList变化,同步更新displayList
|
||||
watch(dataList, () => {
|
||||
// 从Vuex Store中获取用户信息
|
||||
const superUser = store.state.auth.superUser
|
||||
const userName = store.state.auth.userName
|
||||
// 从 Store 中获取用户信息
|
||||
const superUser = userStore.superUser
|
||||
const userName = userStore.userName
|
||||
if (superUser) displayList.value = dataList.value.filter(data => data.type === props.type)
|
||||
else displayList.value = dataList.value.filter(data => data.type === props.type && data.username === userName)
|
||||
// 排序
|
||||
sortSubscribeOrder()
|
||||
})
|
||||
|
||||
// 加载顺序
|
||||
async function loadSubscribeOrderConfig() {
|
||||
// 顺序配置
|
||||
const local_order = localStorage.getItem('MP_SUBSCRIBE_ORDER')
|
||||
const local_order = localStorage.getItem(localOrderKey)
|
||||
if (local_order) {
|
||||
orderConfig.value = JSON.parse(local_order)
|
||||
} else {
|
||||
const response2 = await api.get('/user/config/SubscribeOrder')
|
||||
if (response2 && response2.data && response2.data.value) {
|
||||
orderConfig.value = response2.data.value
|
||||
localStorage.setItem('MP_SUBSCRIBE_ORDER', JSON.stringify(orderConfig.value))
|
||||
const response = await api.get(`/user/config/${orderRequestKey}`)
|
||||
if (response && response.data && response.data.value) {
|
||||
orderConfig.value = response.data.value
|
||||
localStorage.setItem(localOrderKey, JSON.stringify(orderConfig.value))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,10 +78,10 @@ function sortSubscribeOrder() {
|
||||
if (!orderConfig.value) {
|
||||
return
|
||||
}
|
||||
if (dataList.value.length === 0) {
|
||||
if (displayList.value.length === 0) {
|
||||
return
|
||||
}
|
||||
dataList.value.sort((a, b) => {
|
||||
displayList.value.sort((a, b) => {
|
||||
const aIndex = orderConfig.value.findIndex((item: { id: number }) => item.id === a.id)
|
||||
const bIndex = orderConfig.value.findIndex((item: { id: number }) => item.id === b.id)
|
||||
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex)
|
||||
@@ -83,11 +94,11 @@ async function saveSubscribeOrder() {
|
||||
const orderObj = displayList.value.map(item => ({ id: item.id }))
|
||||
orderConfig.value = orderObj
|
||||
const orderString = JSON.stringify(orderObj)
|
||||
localStorage.setItem('MP_SUBSCRIBE_ORDER', orderString)
|
||||
localStorage.setItem(localOrderKey, orderString)
|
||||
|
||||
// 保存到服务端
|
||||
try {
|
||||
await api.post('/user/config/SubscribeOrder', orderObj)
|
||||
await api.post(`/user/config/${orderRequestKey}`, orderObj)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -98,8 +109,6 @@ async function fetchData() {
|
||||
try {
|
||||
loading.value = true
|
||||
dataList.value = await api.get('subscribe/')
|
||||
// 排序
|
||||
sortSubscribeOrder()
|
||||
loading.value = false
|
||||
isRefreshed.value = true
|
||||
} catch (error) {
|
||||
@@ -107,13 +116,10 @@ async function fetchData() {
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 下拉刷新
|
||||
async function onRefresh({ done }: { done: any }) {
|
||||
await fetchData()
|
||||
done('ok')
|
||||
// 历史记录窗口完成
|
||||
function historyDone() {
|
||||
historyDialog.value = false
|
||||
fetchData()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -131,38 +137,36 @@ onMounted(async () => {
|
||||
|
||||
onActivated(async () => {
|
||||
if (!loading.value) {
|
||||
await fetchData()
|
||||
fetchData()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||
<VPullToRefresh v-model="loading" @load="onRefresh">
|
||||
<draggable
|
||||
v-if="displayList.length > 0"
|
||||
v-model="displayList"
|
||||
@end="saveSubscribeOrder"
|
||||
handle=".cursor-move"
|
||||
item-key="id"
|
||||
tag="div"
|
||||
:component-data="{ class: 'mx-3 grid gap-4 grid-subscribe-card p-1' }"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<SubscribeCard :key="element.id" :media="element" @remove="fetchData" @save="fetchData" />
|
||||
</template>
|
||||
</draggable>
|
||||
<NoDataFound
|
||||
v-if="displayList.length === 0 && isRefreshed"
|
||||
error-code="404"
|
||||
error-title="没有订阅"
|
||||
error-description="请通过搜索添加电影、电视剧订阅。"
|
||||
/>
|
||||
</VPullToRefresh>
|
||||
<draggable
|
||||
v-if="displayList.length > 0"
|
||||
v-model="displayList"
|
||||
@end="saveSubscribeOrder"
|
||||
handle=".cursor-move"
|
||||
item-key="id"
|
||||
tag="div"
|
||||
:component-data="{ class: 'mx-3 grid gap-4 grid-subscribe-card p-1' }"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<SubscribeCard :key="element.id" :media="element" @remove="fetchData" @save="fetchData" />
|
||||
</template>
|
||||
</draggable>
|
||||
<NoDataFound
|
||||
v-if="displayList.length === 0 && isRefreshed"
|
||||
error-code="404"
|
||||
error-title="没有订阅"
|
||||
error-description="请通过搜索添加电影、电视剧订阅。"
|
||||
/>
|
||||
<!-- 底部操作按钮 -->
|
||||
<div>
|
||||
<div v-if="isRefreshed">
|
||||
<VFab
|
||||
v-if="store.state.auth.superUser"
|
||||
v-if="userStore.superUser"
|
||||
icon="mdi-clipboard-edit"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
@@ -173,7 +177,7 @@ onActivated(async () => {
|
||||
:class="{ 'mb-12': appMode }"
|
||||
/>
|
||||
<VFab
|
||||
v-if="store.state.auth.superUser"
|
||||
v-if="userStore.superUser"
|
||||
icon="mdi-history"
|
||||
color="info"
|
||||
location="bottom"
|
||||
@@ -200,11 +204,6 @@ onActivated(async () => {
|
||||
v-model="historyDialog"
|
||||
:type="props.type"
|
||||
@close="historyDialog = false"
|
||||
@save="
|
||||
() => {
|
||||
historyDialog = false
|
||||
fetchData()
|
||||
}
|
||||
"
|
||||
@save="historyDone"
|
||||
/>
|
||||
</template>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user