Merge pull request #2 from thofx/lint_fix

es lint fix
This commit is contained in:
jxxghp
2023-07-22 17:50:21 +08:00
committed by GitHub
87 changed files with 4540 additions and 3386 deletions
+6 -180
View File
@@ -3,186 +3,19 @@ module.exports = {
browser: true, browser: true,
es2021: true, es2021: true,
}, },
extends: [ extends: ['@antfu/eslint-config-vue', 'plugin:sonarjs/recommended'],
'@antfu/eslint-config-vue',
'plugin:vue/vue3-recommended',
'plugin:import/recommended',
'plugin:import/typescript',
'plugin:promise/recommended',
'plugin:sonarjs/recommended',
'plugin:@typescript-eslint/recommended',
// 'plugin:unicorn/recommended',
],
parser: 'vue-eslint-parser',
parserOptions: {
ecmaVersion: 13,
parser: '@typescript-eslint/parser',
sourceType: 'module',
},
plugins: [
'vue',
'@typescript-eslint',
'regex',
],
ignorePatterns: ['src/@iconify/*.js', 'node_modules', 'dist', '*.d.ts'], ignorePatterns: ['src/@iconify/*.js', 'node_modules', 'dist', '*.d.ts'],
plugins: ['regex'],
rules: { rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
// indentation (Already present in TypeScript) 'sonarjs/no-duplicate-string': 'warn',
'comma-spacing': ['error', { before: false, after: true }],
'key-spacing': ['error', { afterColon: true }],
'vue/first-attribute-linebreak': ['error', {
singleline: 'beside',
multiline: 'below',
}],
'antfu/top-level-function': 'off',
// indentation (Already present in TypeScript)
'indent': ['error', 2],
// Enforce trailing comma (Already present in TypeScript)
'comma-dangle': ['error', 'always-multiline'],
// Enforce consistent spacing inside braces of object (Already present in TypeScript)
'object-curly-spacing': ['error', 'always'],
// Disable max-len
'max-len': 'off',
// we don't want it
'semi': ['error', 'never'],
// add parens ony when required in arrow function
'arrow-parens': ['error', 'as-needed'],
// add new line above comment
'newline-before-return': 'error',
// add new line above comment
'lines-around-comment': [
'error',
{
beforeBlockComment: true,
beforeLineComment: true,
allowBlockStart: true,
allowClassStart: true,
allowObjectStart: true,
allowArrayStart: true,
},
],
// Ignore _ as unused variable
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_+$' }],
'array-element-newline': ['error', 'consistent'],
'array-bracket-newline': ['error', 'consistent'],
'vue/multi-word-component-names': 'off',
'padding-line-between-statements': [
'error',
{ blankLine: 'always', prev: 'expression', next: 'const' },
{ blankLine: 'always', prev: 'const', next: 'expression' },
{ blankLine: 'always', prev: 'multiline-const', next: '*' },
{ blankLine: 'always', prev: '*', next: 'multiline-const' },
],
// Plugin: eslint-plugin-import
'import/prefer-default-export': 'off',
'import/newline-after-import': ['error', { count: 1 }],
'no-restricted-imports': ['error', 'vuetify/components'],
// For omitting extension for ts files
'import/extensions': [
'error',
'ignorePackages',
{
js: 'never',
jsx: 'never',
ts: 'never',
tsx: 'never',
},
],
// ignore virtual files
'import/no-unresolved': [2, {
ignore: [
'~pages$',
'virtual:generated-layouts',
// Ignore vite's ?raw imports
'.*\?raw',
],
}],
// Thanks: https://stackoverflow.com/a/63961972/10796681
'no-shadow': 'off',
'@typescript-eslint/no-shadow': ['error'],
'@typescript-eslint/consistent-type-imports': 'error',
// Plugin: eslint-plugin-promise
'promise/always-return': 'off',
'promise/catch-or-return': 'off',
// ESLint plugin vue
'vue/block-tag-newline': 'error',
'vue/component-api-style': 'error',
'vue/component-name-in-template-casing': ['error', 'PascalCase', { registeredComponentsOnly: false }],
'vue/custom-event-name-casing': ['error', 'camelCase', {
ignores: [
'/^(click):[a-z]+((\d)|([A-Z0-9][a-z0-9]+))*([A-Z])?/',
],
}],
'vue/define-macros-order': 'error',
'vue/html-comment-content-newline': 'error',
'vue/html-comment-content-spacing': 'error',
'vue/html-comment-indent': 'error',
'vue/match-component-file-name': 'error',
'vue/no-child-content': 'error',
'vue/require-default-prop': 'off',
// NOTE this rule only supported in SFC, Users of the unplugin-vue-define-options should disable that rule: https://github.com/vuejs/eslint-plugin-vue/issues/1886
// 'vue/no-duplicate-attr-inheritance': 'error',
'vue/no-empty-component-block': 'error',
'vue/no-multiple-objects-in-class': 'error',
'vue/no-reserved-component-names': 'error',
'vue/no-template-target-blank': 'error',
'vue/no-useless-mustaches': 'error',
'vue/no-useless-v-bind': 'error',
'vue/padding-line-between-blocks': 'error',
'vue/prefer-separate-static-class': 'error',
'vue/prefer-true-attribute-shorthand': 'error',
'vue/v-on-function-call': 'error',
'vue/no-restricted-class': ['error', '/^(p|m)(l|r)-/'],
'vue/valid-v-slot': ['error', { 'vue/valid-v-slot': ['error', {
allowModifiers: true, allowModifiers: true,
}], }],
// -- Extension Rules
'vue/no-irregular-whitespace': 'error',
'vue/template-curly-spacing': 'error',
// -- Sonarlint
'sonarjs/no-duplicate-string': 'off',
'sonarjs/no-nested-template-literals': 'off',
// -- Unicorn
// 'unicorn/filename-case': 'off',
// 'unicorn/prevent-abbreviations': ['error', {
// replacements: {
// props: false,
// },
// }],
// Internal Rules
'valid-appcardcode-code-prop': 'error',
'valid-appcardcode-demo-sfc': 'error',
// https://github.com/gmullerb/eslint-plugin-regex // https://github.com/gmullerb/eslint-plugin-regex
'regex/invalid': [ 'regex/invalid': [
'error', 'error',
@@ -213,23 +46,16 @@ module.exports = {
}, },
{ {
regex: 'useLayouts\\(', regex: 'useLayouts\\(',
message: '`useLayouts` composable is only allowed in @layouts & @core directory. Please use `useThemeConfig` composable instead.', message:
'`useLayouts` composable is only allowed in @layouts & @core directory. Please use `useThemeConfig` composable instead.',
files: { files: {
inspect: '^(?!.*(@core|@layouts)).*', inspect: '^(?!.*(@core|@layouts)).*',
}, },
}, },
{
regex: 'import axios from \'axios\'',
replacement: 'import axios from \'@axios\'',
message: 'Use axios instances created in \'src/plugin/axios.ts\' instead of unconfigured axios',
files: {
ignore: '^.*plugins/axios.ts.*',
},
},
], ],
// Ignore files // Ignore files
'\.eslintrc\.js', '.eslintrc.js',
], ],
}, },
settings: { settings: {
+2 -2
View File
@@ -7,7 +7,7 @@
"jsxSingleQuote": true, "jsxSingleQuote": true,
"printWidth": 120, "printWidth": 120,
"proseWrap": "preserve", "proseWrap": "preserve",
"quoteProps": "as-needed", "quoteProps": "preserve",
"requirePragma": false, "requirePragma": false,
"semi": false, "semi": false,
"singleQuote": true, "singleQuote": true,
@@ -16,5 +16,5 @@
"useTabs": false, "useTabs": false,
"vueIndentScriptAndStyle": false, "vueIndentScriptAndStyle": false,
"endOfLine": "lf", "endOfLine": "lf",
"singleAttributePerLine": true "singleAttributePerLine": false
} }
+1
View File
@@ -1,6 +1,7 @@
{ {
"recommendations": [ "recommendations": [
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"mgmcdermott.vscode-language-babel", "mgmcdermott.vscode-language-babel",
"editorconfig.editorconfig", "editorconfig.editorconfig",
"xabikos.javascriptsnippets", "xabikos.javascriptsnippets",
+9 -16
View File
@@ -1,12 +1,13 @@
{ {
"editor.formatOnSave": true, "editor.formatOnSave": true,
"javascript.updateImportsOnFileMove.enabled": "always", "javascript.updateImportsOnFileMove.enabled": "always",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"files.eol": "\n",
"[javascript]": { "[javascript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint" "editor.formatOnSave": false
}, },
"[typescript]": { "[typescript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint", "editor.formatOnSave": false
"editor.autoClosingBrackets": "always"
}, },
"[markdown]": { "[markdown]": {
"editor.defaultFormatter": "DavidAnson.vscode-markdownlint" "editor.defaultFormatter": "DavidAnson.vscode-markdownlint"
@@ -24,25 +25,17 @@
}, },
// Vue // Vue
"[vue]": { "[vue]": {
"editor.defaultFormatter": "Wscats.vue", "editor.formatOnSave": false
}, },
// Extension: Volar // Extension: Volar
"volar.preview.port": 3000, "volar.preview.port": 3000,
"volar.completion.preferredTagNameCase": "pascal", "volar.completion.preferredTagNameCase": "pascal",
// Extension: ESLint
"eslint.options": {
"rulePaths": [
"eslint-internal-rules"
]
},
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": true, "source.fixAll.eslint": true,
"source.fixAll.stylelint": true, "source.fixAll.stylelint": true
"source.organizeImports": true,
}, },
"eslint.alwaysShowStatus": true, "eslint.alwaysShowStatus": true,
"eslint.format.enable": true, "eslint.format.enable": true,
"eslint.packageManager": "yarn",
// Extension: Stylelint // Extension: Stylelint
"stylelint.packageManager": "yarn", "stylelint.packageManager": "yarn",
"stylelint.validate": [ "stylelint.validate": [
@@ -70,7 +63,7 @@
// This color is taken from "Better Comments" Extension (?) // This color is taken from "Better Comments" Extension (?)
"highlightColor": "#3498DB", "highlightColor": "#3498DB",
"styleComment": true, "styleComment": true,
"isItalic": false, "isItalic": false
}, },
{ {
"tag": "👉", "tag": "👉",
@@ -86,8 +79,8 @@
// This color is taken from "Better Comments" Extension (*) // This color is taken from "Better Comments" Extension (*)
"highlightColor": "#FF2D00", "highlightColor": "#FF2D00",
"styleComment": true, "styleComment": true,
"isItalic": false, "isItalic": false
}, }
], ],
// Extension: fabiospampinato.vscode-highlight // Extension: fabiospampinato.vscode-highlight
"highlight.regexFlags": "gi", "highlight.regexFlags": "gi",
+1 -1
View File
@@ -7,7 +7,7 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview --port 5050", "preview": "vite preview --port 5050",
"typecheck": "vue-tsc --noEmit", "typecheck": "vue-tsc --noEmit",
"lint": "eslint . -c .eslintrc.js --fix --rulesdir eslint-internal-rules/ --ext .ts,.js,.vue,.tsx,.jsx", "lint": "eslint . -c .eslintrc.js --fix --ext .ts,.js,.vue,.tsx,.jsx",
"build:icons": "tsc -b src/@iconify && node src/@iconify/build-icons.js", "build:icons": "tsc -b src/@iconify && node src/@iconify/build-icons.js",
"postinstall": "npm run build:icons" "postinstall": "npm run build:icons"
}, },
+1 -1
View File
@@ -67,7 +67,7 @@
}, },
{ {
"name": "设置", "name": "设置",
"url": "./account-settings", "url": "./settings",
"icons": [ "icons": [
{ {
"src": "./cog-icon-192x192.png", "src": "./cog-icon-192x192.png",
+8 -5
View File
@@ -1,15 +1,18 @@
<script lang="ts" setup> <script lang="ts" setup>
// 定义触发的自定义事件 // 定义触发的自定义事件
const emit = defineEmits(["click"]); const emit = defineEmits(['click'])
// 按钮点击 // 按钮点击
const onClick = () => { function onClick() {
emit("click"); emit('click')
}; }
</script> </script>
<template> <template>
<IconBtn class="absolute right-3 top-3" @click="onClick"> <IconBtn
class="absolute right-3 top-3"
@click="onClick"
>
<VIcon icon="mdi-close" /> <VIcon icon="mdi-close" />
</IconBtn> </IconBtn>
</template> </template>
+12 -6
View File
@@ -1,20 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
interface Props { interface Props {
errorCode?: string; errorCode?: string
errorTitle?: string; errorTitle?: string
errorDescription?: string; errorDescription?: string
} }
const props = defineProps<Props>(); const props = defineProps<Props>()
</script> </script>
<template> <template>
<div class="text-center mb-4"> <div class="text-center mb-4">
<!-- 👉 Title and subtitle --> <!-- 👉 Title and subtitle -->
<h1 v-if="props.errorCode" class="text-h1 font-weight-medium"> <h1
v-if="props.errorCode"
class="text-h1 font-weight-medium"
>
{{ props.errorCode }} {{ props.errorCode }}
</h1> </h1>
<h5 v-if="props.errorTitle" class="text-h5 font-weight-medium mb-3"> <h5
v-if="props.errorTitle"
class="text-h5 font-weight-medium mb-3"
>
{{ props.errorTitle }} {{ props.errorTitle }}
</h5> </h5>
<p v-if="props.errorDescription"> <p v-if="props.errorDescription">
+3 -7
View File
@@ -1,12 +1,8 @@
<template> <template>
<div class="absolute top-0 right-0 flex items-center justify-between p-2"> <div class="absolute top-0 right-0 flex items-center justify-between p-2">
<div class="pointer-events-none z-40 flex items-center"> <div class="pointer-events-none z-40 flex items-center">
<div <div class="relative inline-flex whitespace-nowrap rounded-full border-gray-700 font-semibold leading-5 ring-gray-700">
class="relative inline-flex whitespace-nowrap rounded-full border-gray-700 font-semibold leading-5 ring-gray-700" <div class="rounded-full bg-opacity-80 shadow-md w-5 border p-0 bg-green-500 border-green-400 ring-green-400 text-green-100">
>
<div
class="rounded-full bg-opacity-80 shadow-md w-5 border p-0 bg-green-500 border-green-400 ring-green-400 text-green-100"
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -17,7 +13,7 @@
fill-rule="evenodd" fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd" clip-rule="evenodd"
></path> />
</svg> </svg>
</div> </div>
</div> </div>
+12 -5
View File
@@ -1,18 +1,25 @@
<script lang="ts" setup> <script lang="ts" setup>
interface Props { interface Props {
menuList?: unknown[]; menuList?: unknown[]
itemProps?: boolean; itemProps?: boolean
} }
const props = defineProps<Props>(); const props = defineProps<Props>()
</script> </script>
<template> <template>
<IconBtn> <IconBtn>
<VIcon icon="mdi-dots-vertical" /> <VIcon icon="mdi-dots-vertical" />
<VMenu v-if="props.menuList" activator="parent" close-on-content-click> <VMenu
<VList :items="props.menuList" :item-props="props.itemProps" /> v-if="props.menuList"
activator="parent"
close-on-content-click
>
<VList
:items="props.menuList"
:item-props="props.itemProps"
/>
</VMenu> </VMenu>
</IconBtn> </IconBtn>
</template> </template>
+108 -21
View File
@@ -1,45 +1,116 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ThemeSwitcherTheme } from "@layouts/types"; import { ref, watch } from 'vue'
import { ref, watch } from "vue"; import { useTheme } from 'vuetify'
import { useTheme } from "vuetify"; import type { ThemeSwitcherTheme } from '@layouts/types'
const props = defineProps<{ const props = defineProps<{
themes: ThemeSwitcherTheme[]; themes: ThemeSwitcherTheme[]
}>(); }>()
const { name: themeName, global: globalTheme } = useTheme(); const { name: themeName, global: globalTheme } = useTheme()
const savedTheme = ref(localStorage.getItem("theme") ?? themeName); const savedTheme = ref(localStorage.getItem('theme') ?? themeName)
const { const {
state: currentThemeName, state: currentThemeName,
next: getNextThemeName, next: getNextThemeName,
index: currentThemeIndex, index: currentThemeIndex,
} = useCycleList( } = useCycleList(
props.themes.map((t) => t.name), props.themes.map(t => t.name),
{ initialValue: savedTheme.value } { initialValue: savedTheme.value },
); )
const changeTheme = () => { function changeTheme() {
const nextTheme = getNextThemeName(); const nextTheme = getNextThemeName()
globalTheme.name.value = nextTheme; globalTheme.name.value = nextTheme
savedTheme.value = nextTheme; savedTheme.value = nextTheme
localStorage.setItem("theme", nextTheme); localStorage.setItem('theme', nextTheme)
};
themeTransition()
}
// Update icon if theme is changed from other sources // Update icon if theme is changed from other sources
watch( watch(
() => globalTheme.name.value, () => globalTheme.name.value,
(val) => { (val) => {
currentThemeName.value = val; currentThemeName.value = val
} },
); )
// Apply saved theme on page load // Apply saved theme on page load
onMounted(() => { onMounted(() => {
globalTheme.name.value = savedTheme.value; globalTheme.name.value = savedTheme.value
}); })
function hasScrollbar(el?: Element | null) {
if (!el || el.nodeType !== Node.ELEMENT_NODE)
return false
const style = window.getComputedStyle(el)
return style.overflowY === 'scroll' || (style.overflowY === 'auto' && el.scrollHeight > el.clientHeight)
}
function themeTransition() {
const x = performance.now()
for (let i = 0; i++ < 1e7; (i << 9) & ((9 % 9) * 9 + 9));
const cost = performance.now() - x
if (cost > 10)
return
const el: HTMLElement = document.querySelector('[data-v-app]')!
const children = el.querySelectorAll('*') as NodeListOf<HTMLElement>
children.forEach((el) => {
if (hasScrollbar(el)) {
el.dataset.scrollX = String(el.scrollLeft)
el.dataset.scrollY = String(el.scrollTop)
}
})
const copy = el.cloneNode(true) as HTMLElement
copy.classList.add('app-copy')
const rect = el.getBoundingClientRect()
copy.style.top = `${rect.top}px`
copy.style.left = `${rect.left}px`
copy.style.width = `${rect.width}px`
copy.style.height = `${rect.height}px`
const targetEl = document.activeElement as HTMLElement
const targetRect = targetEl.getBoundingClientRect()
const left = targetRect.left + targetRect.width / 2 + window.scrollX
const top = targetRect.top + targetRect.height / 2 + window.scrollY
el.style.setProperty('--clip-pos', `${left}px ${top}px`)
el.style.removeProperty('--clip-size')
nextTick(() => {
el.classList.add('app-transition')
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.style.setProperty('--clip-size', `${Math.hypot(window.innerWidth, window.innerHeight)}px`)
})
})
})
document.body.append(copy)
; (copy.querySelectorAll('[data-scroll-x], [data-scroll-y]') as NodeListOf<HTMLElement>).forEach((el) => {
el.scrollLeft = +el.dataset.scrollX!
el.scrollTop = +el.dataset.scrollY!
})
function onTransitionend(e: TransitionEvent) {
if (e.target === e.currentTarget) {
copy.remove()
el.removeEventListener('transitionend', onTransitionend)
el.removeEventListener('transitioncancel', onTransitionend)
el.classList.remove('app-transition')
el.style.removeProperty('--clip-size')
el.style.removeProperty('--clip-pos')
}
}
el.addEventListener('transitionend', onTransitionend)
el.addEventListener('transitioncancel', onTransitionend)
}
</script> </script>
<template> <template>
@@ -47,3 +118,19 @@ onMounted(() => {
<VIcon :icon="props.themes[currentThemeIndex].icon" /> <VIcon :icon="props.themes[currentThemeIndex].icon" />
</IconBtn> </IconBtn>
</template> </template>
<style lang="sass">
// Theme transition
.app-copy
position: fixed !important
z-index: -1 !important
pointer-events: none !important
contain: size style !important
overflow: clip !important
.app-transition
--clip-size: 0
--clip-pos: 0 0
clip-path: circle(var(--clip-size) at var(--clip-pos))
transition: clip-path .35s ease-out
</style>
+13 -13
View File
@@ -2,7 +2,7 @@ import type { ThemeInstance } from 'vuetify'
import { hexToRgb } from '@layouts/utils' import { hexToRgb } from '@layouts/utils'
// 👉 Colors variables // 👉 Colors variables
const colorVariables = (themeColors: ThemeInstance['themes']['value']['colors']) => { function colorVariables(themeColors: ThemeInstance['themes']['value']['colors']) {
const themeSecondaryTextColor = `rgba(${hexToRgb(themeColors.colors['on-surface'])},${themeColors.variables['medium-emphasis-opacity']})` const themeSecondaryTextColor = `rgba(${hexToRgb(themeColors.colors['on-surface'])},${themeColors.variables['medium-emphasis-opacity']})`
const themeDisabledTextColor = `rgba(${hexToRgb(themeColors.colors['on-surface'])},${themeColors.variables['disabled-opacity']})` const themeDisabledTextColor = `rgba(${hexToRgb(themeColors.colors['on-surface'])},${themeColors.variables['disabled-opacity']})`
const themeBorderColor = `rgba(${hexToRgb(String(themeColors.variables['border-color']))},${themeColors.variables['border-opacity']})` const themeBorderColor = `rgba(${hexToRgb(String(themeColors.variables['border-color']))},${themeColors.variables['border-opacity']})`
@@ -11,7 +11,7 @@ const colorVariables = (themeColors: ThemeInstance['themes']['value']['colors'])
return { themeSecondaryTextColor, themeDisabledTextColor, themeBorderColor, themePrimaryTextColor } return { themeSecondaryTextColor, themeDisabledTextColor, themeBorderColor, themePrimaryTextColor }
} }
export const getScatterChartConfig = (themeColors: ThemeInstance['themes']['value']['colors']) => { export function getScatterChartConfig(themeColors: ThemeInstance['themes']['value']['colors']) {
const scatterColors = { const scatterColors = {
series1: '#ff9f43', series1: '#ff9f43',
series2: '#7367f0', series2: '#7367f0',
@@ -67,7 +67,7 @@ export const getScatterChartConfig = (themeColors: ThemeInstance['themes']['valu
}, },
} }
} }
export const getLineChartSimpleConfig = (themeColors: ThemeInstance['themes']['value']['colors']) => { export function getLineChartSimpleConfig(themeColors: ThemeInstance['themes']['value']['colors']) {
const { themeBorderColor, themeDisabledTextColor } = colorVariables(themeColors) const { themeBorderColor, themeDisabledTextColor } = colorVariables(themeColors)
return { return {
@@ -94,7 +94,7 @@ export const getLineChartSimpleConfig = (themeColors: ThemeInstance['themes']['v
}, },
}, },
tooltip: { tooltip: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
custom(data: any) { custom(data: any) {
return `<div class='bar-chart pa-2'> return `<div class='bar-chart pa-2'>
<span>${data.series[data.seriesIndex][data.dataPointIndex]}%</span> <span>${data.series[data.seriesIndex][data.dataPointIndex]}%</span>
@@ -137,7 +137,7 @@ export const getLineChartSimpleConfig = (themeColors: ThemeInstance['themes']['v
} }
} }
export const getBarChartConfig = (themeColors: ThemeInstance['themes']['value']['colors']) => { export function getBarChartConfig(themeColors: ThemeInstance['themes']['value']['colors']) {
const { themeBorderColor, themeDisabledTextColor } = colorVariables(themeColors) const { themeBorderColor, themeDisabledTextColor } = colorVariables(themeColors)
return { return {
@@ -180,7 +180,7 @@ export const getBarChartConfig = (themeColors: ThemeInstance['themes']['value'][
} }
} }
export const getCandlestickChartConfig = (themeColors: ThemeInstance['themes']['value']['colors']) => { export function getCandlestickChartConfig(themeColors: ThemeInstance['themes']['value']['colors']) {
const candlestickColors = { const candlestickColors = {
series1: '#28c76f', series1: '#28c76f',
series2: '#ea5455', series2: '#ea5455',
@@ -231,7 +231,7 @@ export const getCandlestickChartConfig = (themeColors: ThemeInstance['themes']['
}, },
} }
} }
export const getRadialBarChartConfig = (themeColors: ThemeInstance['themes']['value']['colors']) => { export function getRadialBarChartConfig(themeColors: ThemeInstance['themes']['value']['colors']) {
const radialBarColors = { const radialBarColors = {
series1: '#fdd835', series1: '#fdd835',
series2: '#32baff', series2: '#32baff',
@@ -282,7 +282,7 @@ export const getRadialBarChartConfig = (themeColors: ThemeInstance['themes']['va
fontSize: '1.125rem', fontSize: '1.125rem',
color: themePrimaryTextColor, color: themePrimaryTextColor,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter(w: { globals: { seriesTotals: any[]; series: string | any[] } }) { formatter(w: { globals: { seriesTotals: any[]; series: string | any[] } }) {
const totalValue const totalValue
= w.globals.seriesTotals.reduce((a: number, b: number) => { = w.globals.seriesTotals.reduce((a: number, b: number) => {
@@ -307,7 +307,7 @@ export const getRadialBarChartConfig = (themeColors: ThemeInstance['themes']['va
} }
} }
export const getDonutChartConfig = (themeColors: ThemeInstance['themes']['value']['colors']) => { export function getDonutChartConfig(themeColors: ThemeInstance['themes']['value']['colors']) {
const donutColors = { const donutColors = {
series1: '#fdd835', series1: '#fdd835',
series2: '#00d4bd', series2: '#00d4bd',
@@ -401,7 +401,7 @@ export const getDonutChartConfig = (themeColors: ThemeInstance['themes']['value'
} }
} }
export const getAreaChartSplineConfig = (themeColors: ThemeInstance['themes']['value']['colors']) => { export function getAreaChartSplineConfig(themeColors: ThemeInstance['themes']['value']['colors']) {
const areaColors = { const areaColors = {
series3: '#e0cffe', series3: '#e0cffe',
series2: '#b992fe', series2: '#b992fe',
@@ -482,7 +482,7 @@ export const getAreaChartSplineConfig = (themeColors: ThemeInstance['themes']['v
} }
} }
export const getColumnChartConfig = (themeColors: ThemeInstance['themes']['value']['colors']) => { export function getColumnChartConfig(themeColors: ThemeInstance['themes']['value']['colors']) {
const columnColors = { const columnColors = {
series1: '#826af9', series1: '#826af9',
series2: '#d2b0ff', series2: '#d2b0ff',
@@ -568,7 +568,7 @@ export const getColumnChartConfig = (themeColors: ThemeInstance['themes']['value
} }
} }
export const getHeatMapChartConfig = (themeColors: ThemeInstance['themes']['value']['colors']) => { export function getHeatMapChartConfig(themeColors: ThemeInstance['themes']['value']['colors']) {
const { themeSecondaryTextColor, themeDisabledTextColor } = colorVariables(themeColors) const { themeSecondaryTextColor, themeDisabledTextColor } = colorVariables(themeColors)
return { return {
@@ -627,7 +627,7 @@ export const getHeatMapChartConfig = (themeColors: ThemeInstance['themes']['valu
} }
} }
export const getRadarChartConfig = (themeColors: ThemeInstance['themes']['value']['colors']) => { export function getRadarChartConfig(themeColors: ThemeInstance['themes']['value']['colors']) {
const radarColors = { const radarColors = {
series1: '#9b88fa', series1: '#9b88fa',
series2: '#ffa1a1', series2: '#ffa1a1',
+34 -37
View File
@@ -1,6 +1,6 @@
import { isToday } from './index' import { isToday } from './index'
export const avatarText = (value: string) => { export function avatarText(value: string) {
if (!value) if (!value)
return '' return ''
const nameArray = value.split(' ') const nameArray = value.split(' ')
@@ -9,7 +9,7 @@ export const avatarText = (value: string) => {
} }
// TODO: Try to implement this: https://twitter.com/fireship_dev/status/1565424801216311297 // TODO: Try to implement this: https://twitter.com/fireship_dev/status/1565424801216311297
export const kFormatter = (num: number) => { export function kFormatter(num: number) {
const regex = /\B(?=(\d{3})+(?!\d))/g const regex = /\B(?=(\d{3})+(?!\d))/g
return Math.abs(num) > 9999 ? `${Math.sign(num) * +((Math.abs(num) / 1000).toFixed(1))}k` : Math.abs(num).toFixed(0).replace(regex, ',') return Math.abs(num) > 9999 ? `${Math.sign(num) * +((Math.abs(num) / 1000).toFixed(1))}k` : Math.abs(num).toFixed(0).replace(regex, ',')
@@ -22,7 +22,7 @@ export const kFormatter = (num: number) => {
* @param {String} value date to format * @param {String} value date to format
* @param {Intl.DateTimeFormatOptions} formatting Intl object to format with * @param {Intl.DateTimeFormatOptions} formatting Intl object to format with
*/ */
export const formatDate = (value: string, formatting: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' }) => { export function formatDate(value: string, formatting: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' }) {
if (!value) if (!value)
return value return value
@@ -35,7 +35,7 @@ export const formatDate = (value: string, formatting: Intl.DateTimeFormatOptions
* @param {String} value date to format * @param {String} value date to format
* @param {Boolean} toTimeForCurrentDay Shall convert to time if day is today/current * @param {Boolean} toTimeForCurrentDay Shall convert to time if day is today/current
*/ */
export const formatDateToMonthShort = (value: string, toTimeForCurrentDay = true) => { export function formatDateToMonthShort(value: string, toTimeForCurrentDay = true) {
const date = new Date(value) const date = new Date(value)
let formatting: Record<string, string> = { month: 'short', day: 'numeric' } let formatting: Record<string, string> = { month: 'short', day: 'numeric' }
@@ -51,50 +51,47 @@ export const prefixWithPlus = (value: number) => value > 0 ? `+${value}` : value
export const formatSeason = (value: string) => value ? `S${value.padStart(2, '0')}` : '' export const formatSeason = (value: string) => value ? `S${value.padStart(2, '0')}` : ''
// 格式化为xx[TGMK]B // 格式化为xx[TGMK]B
export const formatFileSize = (bytes: number) => { export function formatFileSize(bytes: number) {
if (bytes < 0) { if (bytes < 0)
throw new Error("字节数不能为负数。"); throw new Error('字节数不能为负数。')
}
const units = ["B", "K", "M", "G", "T"]; const units = ['B', 'K', 'M', 'G', 'T']
let size = bytes; let size = bytes
let unitIndex = 0; let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) { while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024; size /= 1024
unitIndex++; unitIndex++
} }
return `${size.toFixed(2)} ${units[unitIndex]}`; return `${size.toFixed(2)} ${units[unitIndex]}`
} }
// 将时间秒格式化为时分秒 // 将时间秒格式化为时分秒
export const formatSeconds = (seconds: number) => { export function formatSeconds(seconds: number) {
const hours = Math.floor(seconds / 3600); const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60); const minutes = Math.floor((seconds % 3600) / 60)
const remainingSeconds = seconds % 60; const remainingSeconds = seconds % 60
let formattedTime = ''; let formattedTime = ''
if (hours > 0) { if (hours > 0)
formattedTime += `${hours}小时`; formattedTime += `${hours}小时`
if (minutes > 0)
formattedTime += `${minutes}`
if ((remainingSeconds > 0 || formattedTime === '') && hours <= 0)
formattedTime += `${remainingSeconds}`
return formattedTime
} }
if (minutes > 0) {
formattedTime += `${minutes}`;
}
if ((remainingSeconds > 0 || formattedTime === '') && hours <= 0) {
formattedTime += `${remainingSeconds}`;
}
return formattedTime;
}
// YYYY-MM-DD 转化为Date // YYYY-MM-DD 转化为Date
export const parseDate = (dateString: string): Date => { export function parseDate(dateString: string): Date {
if (!dateString) return new Date(); if (!dateString)
const [year, month, day] = dateString.split('-').map(Number); return new Date()
return new Date(year, month - 1, day); const [year, month, day] = dateString.split('-').map(Number)
return new Date(year, month - 1, day)
} }
+34 -29
View File
@@ -1,5 +1,5 @@
// 👉 IsEmpty // 👉 IsEmpty
export const isEmpty = (value: unknown): boolean => { export function isEmpty(value: unknown): boolean {
if (value === null || value === undefined || value === '') if (value === null || value === undefined || value === '')
return true return true
@@ -7,20 +7,21 @@ export const isEmpty = (value: unknown): boolean => {
} }
// 👉 IsNullOrUndefined // 👉 IsNullOrUndefined
export const isNullOrUndefined = (value: unknown): value is undefined | null => { export function isNullOrUndefined(value: unknown): value is undefined | null {
return value === null || value === undefined return value === null || value === undefined
} }
// 👉 IsEmptyArray // 👉 IsEmptyArray
export const isEmptyArray = (arr: unknown): boolean => { export function isEmptyArray(arr: unknown): boolean {
return Array.isArray(arr) && arr.length === 0 return Array.isArray(arr) && arr.length === 0
} }
// 👉 IsObject // 👉 IsObject
export const isObject = (obj: unknown): obj is Record<string, unknown> => export function isObject(obj: unknown): obj is Record<string, unknown> {
obj !== null && !!obj && typeof obj === 'object' && !Array.isArray(obj) return obj !== null && !!obj && typeof obj === 'object' && !Array.isArray(obj)
}
export const isToday = (date: Date) => { export function isToday(date: Date) {
const today = new Date() const today = new Date()
return ( return (
@@ -33,38 +34,42 @@ export const isToday = (date: Date) => {
} }
// 计算时间差,返回xx天xx小时xx分钟 // 计算时间差,返回xx天xx小时xx分钟
export const calculateTimeDifference = (inputTime: string): string => { export function calculateTimeDifference(inputTime: string): string {
if (!inputTime)
return ''
if (!inputTime) { const inputDate = new Date(inputTime)
return ''; const currentDate = new Date()
}
const inputDate = new Date(inputTime); const timeDifference = currentDate.getTime() - inputDate.getTime()
const currentDate = new Date(); const secondsDifference = Math.floor(timeDifference / 1000)
const timeDifference = currentDate.getTime() - inputDate.getTime();
const secondsDifference = Math.floor(timeDifference / 1000);
if (secondsDifference < 60) { if (secondsDifference < 60) {
return `${secondsDifference}`; return `${secondsDifference}`
} else if (secondsDifference < 3600) { }
const minutes = Math.floor(secondsDifference / 60); else if (secondsDifference < 3600) {
return `${minutes}分钟`; const minutes = Math.floor(secondsDifference / 60)
} else if (secondsDifference < 86400) {
const hours = Math.floor(secondsDifference / 3600); return `${minutes}分钟`
return `${hours}小时`; }
} else { else if (secondsDifference < 86400) {
const days = Math.floor(secondsDifference / 86400); const hours = Math.floor(secondsDifference / 3600)
return `${days}`;
return `${hours}小时`
}
else {
const days = Math.floor(secondsDifference / 86400)
return `${days}`
} }
} }
// 判断一个数组subArray是不是在另一个数组mainArray中 // 判断一个数组subArray是不是在另一个数组mainArray中
export const isContained = (subArray: any[], mainArray: any[]): boolean => { export function isContained(subArray: any[], mainArray: any[]): boolean {
return subArray.every(element => mainArray.includes(element)); return subArray.every(element => mainArray.includes(element))
} }
// 判断两个数组是否存在交集 // 判断两个数组是否存在交集
export const isIntersected = (array1: any[], array2: any[]): boolean => { export function isIntersected(array1: any[], array2: any[]): boolean {
return array1.some(element => array2.includes(element)); return array1.some(element => array2.includes(element))
} }
+7 -3
View File
@@ -250,6 +250,7 @@ const target = join(__dirname, 'icons-bundle.js');
// Export to JSON // Export to JSON
const content = iconSet.export() const content = iconSet.export()
bundle += `addCollection(${JSON.stringify(content)});\n` bundle += `addCollection(${JSON.stringify(content)});\n`
} }
} }
@@ -258,7 +259,7 @@ const target = join(__dirname, 'icons-bundle.js');
await fs.writeFile(target, bundle, 'utf8') await fs.writeFile(target, bundle, 'utf8')
console.log(`Saved ${target} (${bundle.length} bytes)`) console.log(`Saved ${target} (${bundle.length} bytes)`)
})().catch(err => { })().catch((err) => {
console.error(err) console.error(err)
}) })
@@ -274,7 +275,8 @@ function removeMetaData(iconSet: IconifyJSON) {
'prefixes', 'prefixes',
'suffixes', 'suffixes',
] ]
props.forEach(prop => {
props.forEach((prop) => {
delete iconSet[prop] delete iconSet[prop]
}) })
} }
@@ -284,12 +286,14 @@ function removeMetaData(iconSet: IconifyJSON) {
*/ */
function organizeIconsList(icons: string[]): Record<string, string[]> { function organizeIconsList(icons: string[]): Record<string, string[]> {
const sorted: Record<string, string[]> = Object.create(null) const sorted: Record<string, string[]> = Object.create(null)
icons.forEach(icon => {
icons.forEach((icon) => {
const item = stringToIcon(icon) const item = stringToIcon(icon)
if (!item) if (!item)
return return
const prefix = item.prefix const prefix = item.prefix
const prefixList = sorted[prefix] const prefixList = sorted[prefix]
? sorted[prefix] ? sorted[prefix]
: (sorted[prefix] = []) : (sorted[prefix] = [])
-1
View File
@@ -1,4 +1,3 @@
export { default as VerticalNavLayout } from './components/VerticalNavLayout.vue' export { default as VerticalNavLayout } from './components/VerticalNavLayout.vue'
export { default as VerticalNavLink } from './components/VerticalNavLink.vue' export { default as VerticalNavLink } from './components/VerticalNavLink.vue'
export { default as VerticalNavSectionTitle } from './components/VerticalNavSectionTitle.vue' export { default as VerticalNavSectionTitle } from './components/VerticalNavSectionTitle.vue'
+9 -17
View File
@@ -28,12 +28,13 @@ watch(
() => route.path, () => route.path,
() => { () => {
props.toggleIsOverlayNavActive(false) props.toggleIsOverlayNavActive(false)
}) },
)
const isVerticalNavScrolled = ref(false) const isVerticalNavScrolled = ref(false)
const updateIsVerticalNavScrolled = (val: boolean) => isVerticalNavScrolled.value = val const updateIsVerticalNavScrolled = (val: boolean) => (isVerticalNavScrolled.value = val)
const handleNavScroll = (evt: Event) => { function handleNavScroll(evt: Event) {
isVerticalNavScrolled.value = (evt.target as HTMLElement).scrollTop > 0 isVerticalNavScrolled.value = (evt.target as HTMLElement).scrollTop > 0
} }
</script> </script>
@@ -54,14 +55,8 @@ const handleNavScroll = (evt: Event) => {
<!-- 👉 Header --> <!-- 👉 Header -->
<div class="nav-header"> <div class="nav-header">
<slot name="nav-header"> <slot name="nav-header">
<RouterLink <RouterLink to="/" class="app-logo d-flex align-center gap-x-3 app-title-wrapper">
to="/" <div class="d-flex" v-html="logo" />
class="app-logo d-flex align-center gap-x-3 app-title-wrapper"
>
<div
class="d-flex"
v-html="logo"
/>
<h1 class="font-weight-medium leading-normal text-xl"> <h1 class="font-weight-medium leading-normal text-xl">
MoviePilot MoviePilot
@@ -72,10 +67,7 @@ const handleNavScroll = (evt: Event) => {
<slot name="before-nav-items"> <slot name="before-nav-items">
<div class="vertical-nav-items-shadow" /> <div class="vertical-nav-items-shadow" />
</slot> </slot>
<slot <slot name="nav-items" :update-is-vertical-nav-scrolled="updateIsVerticalNavScrolled">
name="nav-items"
:update-is-vertical-nav-scrolled="updateIsVerticalNavScrolled"
>
<PerfectScrollbar <PerfectScrollbar
tag="ul" tag="ul"
class="nav-items" class="nav-items"
@@ -91,8 +83,8 @@ const handleNavScroll = (evt: Event) => {
</template> </template>
<style lang="scss"> <style lang="scss">
@use "@configured-variables" as variables; @use '@configured-variables' as variables;
@use "@layouts/styles/mixins"; @use '@layouts/styles/mixins';
// 👉 Vertical Nav // 👉 Vertical Nav
.layout-vertical-nav { .layout-vertical-nav {
+48 -45
View File
@@ -1,27 +1,28 @@
<script lang="ts"> <script lang="ts">
import VerticalNav from "@layouts/components/VerticalNav.vue"; import { Transition } from 'vue'
import { useDisplay } from "vuetify"; import { useDisplay } from 'vuetify'
import VerticalNav from '@layouts/components/VerticalNav.vue'
export default defineComponent({ export default defineComponent({
setup(props, { slots }) { setup(props, { slots }) {
const isOverlayNavActive = ref(false); const isOverlayNavActive = ref(false)
const isLayoutOverlayVisible = ref(false); const isLayoutOverlayVisible = ref(false)
const toggleIsOverlayNavActive = useToggle(isOverlayNavActive); const toggleIsOverlayNavActive = useToggle(isOverlayNavActive)
const route = useRoute(); const route = useRoute()
const { mdAndDown } = useDisplay(); const { mdAndDown } = useDisplay()
// ️ This is alternative to below two commented watcher // ️ This is alternative to below two commented watcher
// We want to show overlay if overlay nav is visible and want to hide overlay if overlay is hidden and vice versa. // We want to show overlay if overlay nav is visible and want to hide overlay if overlay is hidden and vice versa.
syncRef(isOverlayNavActive, isLayoutOverlayVisible); syncRef(isOverlayNavActive, isLayoutOverlayVisible)
const scrollDistance = ref(window.scrollY); const scrollDistance = ref(window.scrollY)
onMounted(() => { onMounted(() => {
window.addEventListener("scroll", () => { window.addEventListener('scroll', () => {
scrollDistance.value = window.scrollY; scrollDistance.value = window.scrollY
}); })
}); })
return () => { return () => {
// 👉 Vertical nav // 👉 Vertical nav
@@ -29,63 +30,65 @@ export default defineComponent({
VerticalNav, VerticalNav,
{ isOverlayNavActive: isOverlayNavActive.value, toggleIsOverlayNavActive }, { isOverlayNavActive: isOverlayNavActive.value, toggleIsOverlayNavActive },
{ {
"nav-header": () => slots["vertical-nav-header"]?.(), 'nav-header': () => slots['vertical-nav-header']?.(),
"before-nav-items": () => slots["before-vertical-nav-items"]?.(), 'before-nav-items': () => slots['before-vertical-nav-items']?.(),
default: () => slots["vertical-nav-content"]?.(), 'default': () => slots['vertical-nav-content']?.(),
"after-nav-items": () => slots["after-vertical-nav-items"]?.(), 'after-nav-items': () => slots['after-vertical-nav-items']?.(),
} },
); )
// 👉 Navbar // 👉 Navbar
const navbar = h("header", { class: ["layout-navbar navbar-blur"] }, [ const navbar = h('header', { class: ['layout-navbar navbar-blur'] }, [
h( h(
"div", 'div',
{ class: "navbar-content-container" }, { class: 'navbar-content-container' },
slots.navbar?.({ slots.navbar?.({
toggleVerticalOverlayNavActive: toggleIsOverlayNavActive, toggleVerticalOverlayNavActive: toggleIsOverlayNavActive,
}) }),
), ),
]); ])
const main = h( const main = h(
"main", 'main',
{ class: "layout-page-content" }, { class: 'layout-page-content' },
h("div", { class: "page-content-container" }, slots.default?.()) h(Transition, { name: 'fade-slide', mode: 'out-in', appear: true },
); h('section', { class: 'page-content-container' }, slots.default?.()),
),
)
// 👉 Footer // 👉 Footer
const footer = h("footer", { class: "layout-footer" }, [ const footer = h('footer', { class: 'layout-footer' }, [
h("div", { class: "footer-content-container" }, slots.footer?.()), h('div', { class: 'footer-content-container' }, slots.footer?.()),
]); ])
// 👉 Overlay // 👉 Overlay
const layoutOverlay = h("div", { const layoutOverlay = h('div', {
class: ["layout-overlay", { visible: isLayoutOverlayVisible.value }], class: ['layout-overlay', { visible: isLayoutOverlayVisible.value }],
onClick: () => { onClick: () => {
isLayoutOverlayVisible.value = !isLayoutOverlayVisible.value; isLayoutOverlayVisible.value = !isLayoutOverlayVisible.value
}, },
}); })
return h( return h(
"div", 'div',
{ {
class: [ class: [
"layout-wrapper layout-nav-type-vertical layout-navbar-static layout-footer-static layout-content-width-fluid", 'layout-wrapper layout-nav-type-vertical layout-navbar-static layout-footer-static layout-content-width-fluid',
"layout-navbar-fixed", 'layout-navbar-fixed',
mdAndDown.value && "layout-overlay-nav", mdAndDown.value && 'layout-overlay-nav',
route.meta.layoutWrapperClasses, route.meta.layoutWrapperClasses,
scrollDistance.value && "window-scrolled", scrollDistance.value && 'window-scrolled',
], ],
}, },
[ [
verticalNav, verticalNav,
h("div", { class: "layout-content-wrapper" }, [navbar, main, footer]), h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]),
layoutOverlay, layoutOverlay,
] ],
); )
}; }
}, },
}); })
</script> </script>
<style lang="scss"> <style lang="scss">
+2 -2
View File
@@ -6,11 +6,11 @@
html { html {
min-height: calc(100% + env(safe-area-inset-top)); min-height: calc(100% + env(safe-area-inset-top));
background: rgb(var(--v-theme-background)); //background: rgb(var(--v-theme-background));
} }
body { body {
background: rgb(var(--v-theme-background)); //background: rgb(var(--v-theme-background));
overscroll-behavior-y: contain; overscroll-behavior-y: contain;
--webkit-overflow-scrolling: touch; --webkit-overflow-scrolling: touch;
} }
+1 -1
View File
@@ -24,7 +24,7 @@ export const getComputedNavLinkToProp = computed(() => (link: NavLink) => {
* @param hex * @param hex
*/ */
export const hexToRgb = (hex: string) => { export function hexToRgb(hex: string) {
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF") // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i
+1 -1
View File
@@ -1,4 +1,4 @@
import { ValidationRule } from 'vuetify/types/services/validation' import type { ValidationRule } from 'vuetify/types/services/validation'
// 必输校验 // 必输校验
export const requiredValidator: ValidationRule = (value: any) => !!value || '此项为必填项' export const requiredValidator: ValidationRule = (value: any) => !!value || '此项为必填项'
+38 -36
View File
@@ -1,67 +1,69 @@
<script lang="ts" setup> <script lang="ts" setup>
import avatar1 from "@images/avatars/avatar-1.png"; import { useToast } from 'vue-toast-notification'
import api from './api'
import { useToast } from "vue-toast-notification"; import type { User } from './api/types'
import api from "./api"; import store from './store'
import { User } from "./api/types"; import avatar1 from '@images/avatars/avatar-1.png'
import store from "./store";
// 路由 // 路由
const route = useRoute(); const route = useRoute()
// 提示框 // 提示框
const $toast = useToast(); const $toast = useToast()
// SSE持续接收消息 // SSE持续接收消息
const startSSEMessager = () => { function startSSEMessager() {
const token = store.state.auth.token; const token = store.state.auth.token
if (token) { if (token) {
const eventSource = new EventSource( const eventSource = new EventSource(
`${import.meta.env.VITE_API_BASE_URL}system/message?token=${token}` `${import.meta.env.VITE_API_BASE_URL}system/message?token=${token}`,
); )
eventSource.addEventListener("message", (event) => {
const message = event.data; eventSource.addEventListener('message', (event) => {
if (message) { const message = event.data
$toast.info(message); if (message)
} $toast.info(message)
}); })
onBeforeUnmount(() => { onBeforeUnmount(() => {
eventSource.close(); eventSource.close()
}); })
}
} }
};
// 当前用户信息 // 当前用户信息
const accountInfo = ref<User>({ const accountInfo = ref<User>({
id: 0, id: 0,
name: "", name: '',
password: "", password: '',
email: "", email: '',
is_active: false, is_active: false,
is_superuser: false, is_superuser: false,
avatar: avatar1, avatar: avatar1,
}); })
// 调用API,加载当前用户数据 // 调用API,加载当前用户数据
const loadAccountInfo = async () => { async function loadAccountInfo() {
try { try {
const user: User = await api.get(`user/current`); const user: User = await api.get('user/current')
accountInfo.value = user;
if (!accountInfo.value.avatar) accountInfo.value.avatar = avatar1; accountInfo.value = user
} catch (error) { if (!accountInfo.value.avatar)
console.log(error); accountInfo.value.avatar = avatar1
}
catch (error) {
console.log(error)
}
} }
};
// 页面加载时,加载当前用户数据 // 页面加载时,加载当前用户数据
onMounted(() => { onMounted(() => {
loadAccountInfo(); loadAccountInfo()
startSSEMessager(); startSSEMessager()
}); })
// 提供给所有元素复用 // 提供给所有元素复用
provide("accountInfo", accountInfo); provide('accountInfo', accountInfo)
</script> </script>
<template> <template>
+5 -4
View File
@@ -1,6 +1,6 @@
import axios from 'axios'
import router from '@/router' import router from '@/router'
import store from '@/store' import store from '@/store'
import axios from 'axios'
// 创建axios实例 // 创建axios实例
const api = axios.create({ const api = axios.create({
@@ -8,7 +8,7 @@ const api = axios.create({
}) })
// 添加请求拦截器 // 添加请求拦截器
api.interceptors.request.use(config => { api.interceptors.request.use((config) => {
// 在请求头中添加token // 在请求头中添加token
const token = store.state.auth.token const token = store.state.auth.token
if (token) if (token)
@@ -18,9 +18,9 @@ api.interceptors.request.use(config => {
}) })
// 添加响应拦截器 // 添加响应拦截器
api.interceptors.response.use(response => { api.interceptors.response.use((response) => {
return response.data return response.data
}, error => { }, (error) => {
if (!error.response) { if (!error.response) {
// 请求超时 // 请求超时
return Promise.reject(error) return Promise.reject(error)
@@ -28,6 +28,7 @@ api.interceptors.response.use(response => {
else if (error.response.status === 403) { else if (error.response.status === 403) {
// 清除登录状态信息 // 清除登录状态信息
store.dispatch('auth/clearToken') store.dispatch('auth/clearToken')
// token验证失败,跳转到登录页面 // token验证失败,跳转到登录页面
router.push('/login') router.push('/login')
} }
+6 -6
View File
@@ -1,16 +1,16 @@
import NProgress from 'nprogress'; import NProgress from 'nprogress'
import 'nprogress/nprogress.css'; import 'nprogress/nprogress.css'
export function configureNProgress() { export function configureNProgress() {
NProgress.configure({ NProgress.configure({
showSpinner: false showSpinner: false,
}); })
} }
export function startNProgress() { export function startNProgress() {
NProgress.start(); NProgress.start()
} }
export function doneNProgress() { export function doneNProgress() {
NProgress.done(); NProgress.done()
} }
+421 -221
View File
@@ -1,493 +1,693 @@
// 订阅 // 订阅
export interface Subscribe { export interface Subscribe {
id: number; id: number
// 订阅名称 // 订阅名称
name: string; name: string
// 订阅年份 // 订阅年份
year: string; year: string
// 订阅类型 电影/电视剧 // 订阅类型 电影/电视剧
type: string; type: string
// 搜索关键字 // 搜索关键字
keyword?: string; keyword?: string
tmdbid: number; tmdbid: number
doubanid?: string; doubanid?: string
// 季号 // 季号
season?: number; season?: number
// 海报 // 海报
poster?: string; poster?: string
// 背景图 // 背景图
backdrop?: string; backdrop?: string
// 评分 // 评分
vote?: number; vote?: number
// 描述 // 描述
description?: string; description?: string
// 过滤规则 // 过滤规则
filter?: string; filter?: string
// 包含 // 包含
include?: string; include?: string
// 排除 // 排除
exclude?: string; exclude?: string
// 总集数 // 总集数
total_episode?: number; total_episode?: number
// 开始集数 // 开始集数
start_episode?: number; start_episode?: number
// 缺失集数 // 缺失集数
lack_episode?: number; lack_episode?: number
// 附加信息 // 附加信息
note?: string; note?: string
// 状态:N-新建, R-订阅中 // 状态:N-新建, R-订阅中
state: string; state: string
// 最后更新时间 // 最后更新时间
last_update: string; last_update: string
// 订阅用户 // 订阅用户
username: string; username: string
// 订阅站点 // 订阅站点
sites: number[], sites: number[]
} }
// 历史记录 // 历史记录
export interface TransferHistory { export interface TransferHistory {
// ID // ID
id: number; id: number
// 源目录 // 源目录
src?: string; src?: string
// 目的目录 // 目的目录
dest?: string; dest?: string
// 转移模式link/copy/move/softlink // 转移模式link/copy/move/softlink
mode?: string; mode?: string
// 类型:电影、电视剧 // 类型:电影、电视剧
type?: string; type?: string
// 二级分类 // 二级分类
category?: string; category?: string
// 标题 // 标题
title?: string; title?: string
// 年份 // 年份
year?: string; year?: string
// TMDBID // TMDBID
tmdbid?: number; tmdbid?: number
// IMDBID // IMDBID
imdbid?: string; imdbid?: string
// TVDBID // TVDBID
tvdbid?: number; tvdbid?: number
// 豆瓣ID // 豆瓣ID
doubanid?: string; doubanid?: string
// 季Sxx // 季Sxx
seasons?: string; seasons?: string
// 集Exx // 集Exx
episodes?: string; episodes?: string
// 海报 // 海报
image?: string; image?: string
// 下载器Hash // 下载器Hash
download_hash?: string; download_hash?: string
// 状态 1-成功,0-失败 // 状态 1-成功,0-失败
status: boolean; status: boolean
// 失败原因 // 失败原因
errmsg?: string; errmsg?: string
// 日期 // 日期
date?: string; date?: string
} }
// 媒体信息 // 媒体信息
export interface MediaInfo { export interface MediaInfo {
// 类型 电影、电视剧 // 类型 电影、电视剧
type?: string; type?: string
// 媒体标题 // 媒体标题
title?: string; title?: string
// 年份 // 年份
year?: string; year?: string
// 标题(年) // 标题(年)
title_year?: string; title_year?: string
// 季号 // 季号
season?: number; season?: number
// TMDB ID // TMDB ID
tmdb_id?: number; tmdb_id?: number
// IMDB ID // IMDB ID
imdb_id?: string; imdb_id?: string
// TVDB ID // TVDB ID
tvdb_id?: string; tvdb_id?: string
// 豆瓣ID // 豆瓣ID
douban_id?: string; douban_id?: string
// 媒体原语种 // 媒体原语种
original_language?: string; original_language?: string
// 媒体原发行标题 // 媒体原发行标题
original_title?: string; original_title?: string
// 媒体发行日期 // 媒体发行日期
release_date?: string; release_date?: string
// 背景图片 // 背景图片
backdrop_path?: string; backdrop_path?: string
// 海报图片 // 海报图片
poster_path?: string; poster_path?: string
// 评分 // 评分
vote_average: number; vote_average: number
// 描述 // 描述
overview?: string; overview?: string
// 二级分类 // 二级分类
category?: string; category?: string
// 详情页面 // 详情页面
detail_link?: string; detail_link?: string
} }
// TMDB季信息 // TMDB季信息
export interface TmdbSeason { export interface TmdbSeason {
// 上映日期 // 上映日期
air_date?: string; air_date?: string
// 总集数 // 总集数
episode_count?: number; episode_count?: number
// 季名称 // 季名称
name?: string; name?: string
// 描述 // 描述
overview?: string; overview?: string
// 海报 // 海报
poster_path?: string; poster_path?: string
// 季号 // 季号
season_number?: number; season_number?: number
// 评分 // 评分
vote_average?: number; vote_average?: number
} }
// TMDB集信息 // TMDB集信息
export interface TmdbEpisode { export interface TmdbEpisode {
// 上映日期 // 上映日期
air_date?: string; air_date?: string
// 集号 // 集号
episode_number?: number; episode_number?: number
// 剧集名称 // 剧集名称
name?: string; name?: string
// 描述 // 描述
overview?: string; overview?: string
// 时长 // 时长
runtime?: number; runtime?: number
// 季号 // 季号
season_number?: number; season_number?: number
// 海报 // 海报
still_path?: string; still_path?: string
// 评分 // 评分
vote_average?: number; vote_average?: number
// 演职人员 // 演职人员
crew: Object[]; crew: Object[]
// 嘉宾 // 嘉宾
guest_stars: Object[]; guest_stars: Object[]
} }
// 站点 // 站点
export interface Site { export interface Site {
// ID // ID
id: number; id: number
// 站点名称 // 站点名称
name: string; name: string
// 站点主域名Key // 站点主域名Key
domain: string; domain: string
// 站点地址 // 站点地址
url: string; url: string
// 站点优先级 // 站点优先级
pri?: number; pri?: number
// RSS地址 // RSS地址
rss?: string; rss?: string
// Cookie // Cookie
cookie?: string; cookie?: string
// User-Agent // User-Agent
ua?: string; ua?: string
// 是否使用代理 // 是否使用代理
proxy?: number; proxy?: number
// 过滤规则 // 过滤规则
filter?: string; filter?: string
// 是否演染 // 是否演染
render?: number; render?: number
// 是否公开站点 // 是否公开站点
public?: number; public?: number
// 备注 // 备注
note?: string; note?: string
// 流控单位周期 // 流控单位周期
limit_interval?: number; limit_interval?: number
// 流控次数 // 流控次数
limit_count?: number; limit_count?: number
// 流控间隔 // 流控间隔
limit_seconds?: number; limit_seconds?: number
// 是否启用 // 是否启用
is_active: boolean; is_active: boolean
} }
// 正在下载 // 正在下载
export interface DownloadingInfo { export interface DownloadingInfo {
// HASH // HASH
hash?: string; hash?: string
// 种子名称 // 种子名称
title?: string; title?: string
// 识别后的名称 // 识别后的名称
name?: string; name?: string
// 年份 // 年份
year?: string; year?: string
// SXXEXX // SXXEXX
season_episode?: string; season_episode?: string
// 大小 // 大小
size?: number; size?: number
// 下载进 度 // 下载进 度
progress?: number; progress?: number
// 状态 // 状态
state?: string; state?: string
// 下载速度 // 下载速度
dlspeed?: string; dlspeed?: string
// 上传速度 // 上传速度
upspeed?: string; upspeed?: string
// 媒体信息 // 媒体信息
media: { [key: string]: any }; media: { [key: string]: any }
} }
// 缺失剧集信息 // 缺失剧集信息
export interface NotExistMediaInfo { export interface NotExistMediaInfo {
// 季 // 季
season: number; season: number
// 剧集列表 // 剧集列表
episodes: number[]; episodes: number[]
// 总集数 // 总集数
total_episodes: number; total_episodes: number
// 开始集 // 开始集
start_episode: number; start_episode: number
} }
// 插件 // 插件
export interface Plugin { export interface Plugin {
id?: string; id?: string
// 插件名称 // 插件名称
plugin_name?: string; plugin_name?: string
// 插件描述 // 插件描述
plugin_desc?: string; plugin_desc?: string
// 插件图标 // 插件图标
plugin_icon?: string; plugin_icon?: string
// 主题色 // 主题色
plugin_color?: string; plugin_color?: string
// 插件版本 // 插件版本
plugin_version?: string; plugin_version?: string
// 插件作者 // 插件作者
plugin_author?: string; plugin_author?: string
// 作者主页 // 作者主页
author_url?: string; author_url?: string
// 插件配置项ID前缀 // 插件配置项ID前缀
plugin_config_prefix?: string; plugin_config_prefix?: string
// 加载顺序 // 加载顺序
plugin_order?: number; plugin_order?: number
// 可使用的用户级别 // 可使用的用户级别
auth_level?: number; auth_level?: number
// 是否已安装 // 是否已安装
installed?: boolean; installed?: boolean
} }
// 种子信息 // 种子信息
export interface TorrentInfo { export interface TorrentInfo {
// 站点ID // 站点ID
site?: number; site?: number
// 站点名称 // 站点名称
site_name?: string; site_name?: string
// 站点Cookie // 站点Cookie
site_cookie?: string; site_cookie?: string
// 站点UA // 站点UA
site_ua?: string; site_ua?: string
// 站点是否使用代理 // 站点是否使用代理
site_proxy: boolean; site_proxy: boolean
// 站点优先级 // 站点优先级
site_order: number; site_order: number
// 种子名称 // 种子名称
title?: string; title?: string
// 种子副标题 // 种子副标题
description?: string; description?: string
// IMDB ID // IMDB ID
imdbid: string; imdbid: string
// 种子链接 // 种子链接
enclosure?: string; enclosure?: string
// 详情页面 // 详情页面
page_url?: string; page_url?: string
// 种子大小 // 种子大小
size: number; size: number
// 做种者 // 做种者
seeders: number; seeders: number
// 下载者 // 下载者
peers: number; peers: number
// 完成者 // 完成者
grabs: number; grabs: number
// 发布时间 // 发布时间
pubdate?: string; pubdate?: string
// 已过时间 // 已过时间
date_elapsed?: string; date_elapsed?: string
// 上传因子 // 上传因子
uploadvolumefactor: number; uploadvolumefactor: number
// 下载因子 // 下载因子
downloadvolumefactor: number; downloadvolumefactor: number
// HR // HR
hit_and_run: boolean; hit_and_run: boolean
// 种子标签 // 种子标签
labels: string[]; labels: string[]
// 种子优先级 // 种子优先级
pri_order: number; pri_order: number
// 促销描述 // 促销描述
volume_factor: string; volume_factor: string
} }
// 识别元数据 // 识别元数据
export interface MetaInfo { export interface MetaInfo {
// 是否处理的文件 // 是否处理的文件
isfile: boolean; isfile: boolean
// 原字符串 // 原字符串
org_string?: string; org_string?: string
// 副标题 // 副标题
subtitle?: string; subtitle?: string
// 类型 电影、电视剧 // 类型 电影、电视剧
type: string; type: string
// 识别的中文名 // 识别的中文名
cn_name?: string; cn_name?: string
// 识别的英文名 // 识别的英文名
en_name?: string; en_name?: string
// 年份 // 年份
year?: string; year?: string
// 总季数 // 总季数
total_seasons: number; total_seasons: number
// 识别的开始季 数字 // 识别的开始季 数字
begin_season?: number; begin_season?: number
// 识别的结束季 数字 // 识别的结束季 数字
end_season?: number; end_season?: number
// 总集数 // 总集数
total_episodes: number; total_episodes: number
// 识别的开始集 // 识别的开始集
begin_episode?: number; begin_episode?: number
// 识别的结束集 // 识别的结束集
end_episode?: number; end_episode?: number
// Partx Cd Dvd Disk Disc // Partx Cd Dvd Disk Disc
part?: string; part?: string
// 识别的资源类型 // 识别的资源类型
resource_type?: string; resource_type?: string
// 识别的效果 // 识别的效果
resource_effect?: string; resource_effect?: string
// 识别的分辨率 // 识别的分辨率
resource_pix?: string; resource_pix?: string
// 识别的制作组/字幕组 // 识别的制作组/字幕组
resource_team?: string; resource_team?: string
// 视频编码 // 视频编码
video_encode?: string; video_encode?: string
// 音频编码 // 音频编码
audio_encode?: string; audio_encode?: string
// 名称(自动中英文) // 名称(自动中英文)
name: string; name: string
// SXX-SXX // SXX-SXX
season: string; season: string
// SXX-SXX 有季号才返回 // SXX-SXX 有季号才返回
sea: string; sea: string
// begin_season 的数字,电视剧没有季的返回1 // begin_season 的数字,电视剧没有季的返回1
season_seq: string; season_seq: string
// 季的数组 // 季的数组
season_list: number[]; season_list: number[]
// Exx-Exx // Exx-Exx
episode: string; episode: string
// 集的数组 // 集的数组
episode_list: number[]; episode_list: number[]
// ExxExx // ExxExx
episodes: string; episodes: string
// xx-xx // xx-xx
episode_seqs: string; episode_seqs: string
// begin_episode 的数字 // begin_episode 的数字
episode_seq: string; episode_seq: string
// SxxExx // SxxExx
season_episode: string; season_episode: string
// 资源类型字符串,含分辨率 // 资源类型字符串,含分辨率
resource_term: string; resource_term: string
// 发布组/字幕组字符串 // 发布组/字幕组字符串
release_group: string; release_group: string
// 视频编码 // 视频编码
video_term: string; video_term: string
// 音频编码 // 音频编码
audio_term: string; audio_term: string
// 资源类型+特效 // 资源类型+特效
edition: string; edition: string
} }
// 上下文信息 // 上下文信息
export interface Context { export interface Context {
// 元信息 // 元信息
meta_info: MetaInfo; meta_info: MetaInfo
// 媒体信息 // 媒体信息
media_info: MediaInfo; media_info: MediaInfo
// 种子信息 // 种子信息
torrent_info: TorrentInfo; torrent_info: TorrentInfo
} }
// 用户信息 // 用户信息
export interface User { export interface User {
id: number; id: number
name: string; name: string
password: string; password: string
email: string; email: string
is_active: boolean; is_active: boolean
is_superuser: boolean; is_superuser: boolean
avatar: string; avatar: string
} }
// 存储空间 // 存储空间
export interface Storage { export interface Storage {
total_storage: number; total_storage: number
used_storage: number; used_storage: number
} }
// 媒体统计 // 媒体统计
export interface MediaStatistic { export interface MediaStatistic {
// 电影总数 // 电影总数
movie_count: number; movie_count: number
// 电视剧总数 // 电视剧总数
tv_count: number; tv_count: number
// 电视剧总集数 // 电视剧总集数
episode_count: number; episode_count: number
// 用户数量 // 用户数量
user_count: number; user_count: number
} }
// 后台进程 // 后台进程
export interface Process { export interface Process {
// 进程ID // 进程ID
pid: number; pid: number
// 进程名称 // 进程名称
name: string; name: string
// 进程状态 // 进程状态
status: string; status: string
// 进程启动时间 // 进程启动时间
create_time: number; create_time: number
// 进程运行时间 // 进程运行时间
run_time: number; run_time: number
// 进程CPU占用率 // 进程CPU占用率
cpu: number; cpu: number
// 进程内存占用 // 进程内存占用
memory: number; memory: number
} }
// 下载器信息 // 下载器信息
export interface DownloaderInfo { export interface DownloaderInfo {
// 下载速度 // 下载速度
download_speed: number; download_speed: number
// 上传速度 // 上传速度
upload_speed: number; upload_speed: number
// 下载量 // 下载量
download_size: number; download_size: number
// 上传量 // 上传量
upload_size: number; upload_size: number
// 剩余空间 // 剩余空间
free_space: number; free_space: number
} }
// 定时服务信息 // 定时服务信息
export interface ScheduleInfo { export interface ScheduleInfo {
// ID
id: string;
// 名称
name: string;
// 状态
status: string;
// 下次运行时间
next_run: string;
}
// ID
id: string
// 名称
name: string
// 状态
status: string
// 下次运行时间
next_run: string
}
// 消息通知 // 消息通知
export interface NotificationSwitch { export interface NotificationSwitch {
// 消息类型 // 消息类型
mtype: string; mtype: string
// 开关 // 开关
wechat: boolean; wechat: boolean
telegram: boolean; telegram: boolean
slack: boolean; slack: boolean
} }
+3 -3
View File
@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTheme } from 'vuetify'
import misc404 from '@images/pages/404.png' import misc404 from '@images/pages/404.png'
import miscMaskDark from '@images/pages/misc-mask-dark.png' import miscMaskDark from '@images/pages/misc-mask-dark.png'
import miscMaskLight from '@images/pages/misc-mask-light.png' import miscMaskLight from '@images/pages/misc-mask-light.png'
import tree from '@images/pages/tree.png' import tree from '@images/pages/tree.png'
import { useTheme } from 'vuetify'
const props = defineProps<Props>()
const vuetifyTheme = useTheme() const vuetifyTheme = useTheme()
@@ -16,8 +18,6 @@ interface Props {
errorTitle?: string errorTitle?: string
errorDescription?: string errorDescription?: string
} }
const props = defineProps<Props>()
</script> </script>
<template> <template>
+62 -39
View File
@@ -1,69 +1,74 @@
<script lang="ts" setup> <script lang="ts" setup>
import api from "@/api"; import api from '@/api'
import { DownloadingInfo } from "@/api/types"; import type { DownloadingInfo } from '@/api/types'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
info: Object as PropType<DownloadingInfo>, info: Object as PropType<DownloadingInfo>,
}); })
// 是否显示卡片 // 是否显示卡片
const cardState = ref(true); const cardState = ref(true)
// 进度条 // 进度条
const getPercentage = () => { function getPercentage() {
return props.info?.progress ?? 0; return props.info?.progress ?? 0
}; }
// 速度 // 速度
const getSpeedText = () => { function getSpeedText() {
return `${props.info?.upspeed}/s ↓ ${props.info?.dlspeed}/s`; return `${props.info?.upspeed}/s ↓ ${props.info?.dlspeed}/s`
}; }
// 下载状态 // 下载状态
const isDownloading = ref(props.info?.state === "downloading" ? true : false); const isDownloading = ref(props.info?.state === 'downloading')
// 图片是否加载完成 // 图片是否加载完成
const imageLoaded = ref(false); const imageLoaded = ref(false)
// 图片加载完成响应 // 图片加载完成响应
const imageLoadHandler = () => { function imageLoadHandler() {
imageLoaded.value = true; imageLoaded.value = true
}; }
// 计算文本类 // 计算文本类
const getTextClass = () => { function getTextClass() {
return imageLoaded.value ? "text-white" : ""; return imageLoaded.value ? 'text-white' : ''
}; }
// 下载状态控制 // 下载状态控制
const toggleDownload = async () => { async function toggleDownload() {
let operation = isDownloading.value ? "stop" : "start"; const operation = isDownloading.value ? 'stop' : 'start'
try { try {
const result: { [key: string]: any } = await api.put( const result: { [key: string]: any } = await api.put(
`download/${props.info?.hash}/${operation}` `download/${props.info?.hash}/${operation}`,
); )
if (result.success) {
isDownloading.value = !isDownloading.value; if (result.success)
isDownloading.value = !isDownloading.value
}
catch (error) {
console.error(error)
} }
} catch (error) {
console.error(error);
} }
};
// 删除下截 // 删除下截
const deleteDownload = async () => { async function deleteDownload() {
try { try {
await api.delete(`download/${props.info?.hash}`); await api.delete(`download/${props.info?.hash}`)
cardState.value = false; cardState.value = false
} catch (error) { }
console.error(error); catch (error) {
console.error(error)
}
} }
};
</script> </script>
<template> <template>
<VCard :key="props.info?.hash" v-if="cardState"> <VCard
v-if="cardState"
:key="props.info?.hash"
>
<template #image> <template #image>
<VImg <VImg
:src="props.info?.media.image" :src="props.info?.media.image"
@@ -74,18 +79,32 @@ const deleteDownload = async () => {
/> />
</template> </template>
<VCardTitle class="break-words whitespace-normal" :class="getTextClass()"> <VCardTitle
class="break-words whitespace-normal"
:class="getTextClass()"
>
{{ props.info?.media.title || props.info?.name }} {{ props.info?.media.title || props.info?.name }}
{{ props.info?.season_episode }} {{ props.info?.season_episode }}
</VCardTitle> </VCardTitle>
<VCardSubtitle class="break-words whitespace-normal" :class="getTextClass()"> <VCardSubtitle
class="break-words whitespace-normal"
:class="getTextClass()"
>
{{ props.info?.title }} {{ props.info?.title }}
</VCardSubtitle> </VCardSubtitle>
<VCardText class="text-subtitle-1 pt-3 pb-1" :class="getTextClass()"> {{ getSpeedText() }} </VCardText> <VCardText
class="text-subtitle-1 pt-3 pb-1"
:class="getTextClass()"
>
{{ getSpeedText() }}
</VCardText>
<VCardText v-if="getPercentage() > 0" :class="getTextClass()"> <VCardText
v-if="getPercentage() > 0"
:class="getTextClass()"
>
<VProgressLinear :model-value="getPercentage()" /> <VProgressLinear :model-value="getPercentage()" />
</VCardText> </VCardText>
@@ -93,7 +112,11 @@ const deleteDownload = async () => {
<VBtn @click="toggleDownload"> <VBtn @click="toggleDownload">
<span class="ms-2">{{ isDownloading ? "暂停" : "开始" }}</span> <span class="ms-2">{{ isDownloading ? "暂停" : "开始" }}</span>
</VBtn> </VBtn>
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" /> <VBtn
color="error"
icon="mdi-trash-can-outline"
@click="deleteDownload"
/>
</VCardActions> </VCardActions>
</VCard> </VCard>
</template> </template>
+44 -40
View File
@@ -1,73 +1,77 @@
<script lang="ts" setup> <script lang="ts" setup>
// 定义触发的自定义事件
const emit = defineEmits(["close", "changed"]);
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
pri: String, pri: String,
rules: Array as PropType<string[]>, rules: Array as PropType<string[]>,
width: String, width: String,
height: String, height: String,
}); })
// 定义触发的自定义事件
const emit = defineEmits(['close', 'changed'])
// 按钮点击 // 按钮点击
const onClose = () => { function onClose() {
emit("close"); emit('close')
}; }
// 选项变化 // 选项变化
const filtersChanged = (value: string[]) => { function filtersChanged(value: string[]) {
emit("changed", props.pri, value); emit('changed', props.pri, value)
}; }
// 过滤规则下拉框 // 过滤规则下拉框
const selectFilterOptions = ref<{ [key: string]: string }[]>([ const selectFilterOptions = ref<{ [key: string]: string }[]>([
{ title: "特效字幕", value: " SPECSUB " }, { title: '特效字幕', value: ' SPECSUB ' },
{ title: "中文字幕", value: " CNSUB " }, { title: '中文字幕', value: ' CNSUB ' },
{ title: "分辨率: 4K", value: " 4K " }, { title: '分辨率: 4K', value: ' 4K ' },
{ title: "分辨率: 1080P", value: " 1080P " }, { title: '分辨率: 1080P', value: ' 1080P ' },
{ title: "分辨率: 720P", value: " 720P " }, { title: '分辨率: 720P', value: ' 720P ' },
{ title: "排除: 720P", value: " !720P " }, { title: '排除: 720P', value: ' !720P ' },
{ title: "质量: 蓝光原盘", value: " BLU " }, { title: '质量: 蓝光原盘', value: ' BLU ' },
{ title: "排除: 蓝光原盘", value: " !BLU ", color: "error" }, { title: '排除: 蓝光原盘', value: ' !BLU ', color: 'error' },
{ title: "质量: REMUX", value: " REMUX " }, { title: '质量: REMUX', value: ' REMUX ' },
{ title: "排除: REMUX", value: " !REMUX ", color: "error" }, { title: '排除: REMUX', value: ' !REMUX ', color: 'error' },
{ title: "质量: WEB-DL", value: " WEBDL " }, { title: '质量: WEB-DL', value: ' WEBDL ' },
{ title: "排除: WEB-DL", value: " !WEBDL ", color: "error" }, { title: '排除: WEB-DL', value: ' !WEBDL ', color: 'error' },
{ title: "编码: H265", value: " H265 " }, { title: '编码: H265', value: ' H265 ' },
{ title: "排除: H265", value: " !H265 ", color: "error" }, { title: '排除: H265', value: ' !H265 ', color: 'error' },
{ title: "编码: H264", value: " H264 " }, { title: '编码: H264', value: ' H264 ' },
{ title: "排除: H264", value: " !H264 ", color: "error" }, { title: '排除: H264', value: ' !H264 ', color: 'error' },
{ title: "效果: 杜比视界", value: " DOLBY " }, { title: '效果: 杜比视界', value: ' DOLBY ' },
{ title: "排除: 杜比视界", value: " !DOLBY ", color: "error" }, { title: '排除: 杜比视界', value: ' !DOLBY ', color: 'error' },
{ title: "效果: HDR", value: " HDR " }, { title: '效果: HDR', value: ' HDR ' },
{ title: "排除: HDR", value: " !HDR ", color: "error" }, { title: '排除: HDR', value: ' !HDR ', color: 'error' },
{ title: "国语配音", value: " CNVOI ", color: "error" }, { title: '国语配音', value: ' CNVOI ', color: 'error' },
{ title: "排除: 国语配音", value: " !CNVOI ", color: "error" }, { title: '排除: 国语配音', value: ' !CNVOI ', color: 'error' },
{ title: "促销: 免费", value: " FREE " }, { title: '促销: 免费', value: ' FREE ' },
]); ])
// 已选择的过滤规则 // 已选择的过滤规则
const selectedFilters = ref<string[]>(props.rules ?? []); const selectedFilters = ref<string[]>(props.rules ?? [])
</script> </script>
<template> <template>
<VCard variant="tonal" :width="props.width" :height="props.height"> <VCard
variant="tonal"
:width="props.width"
:height="props.height"
>
<DialogCloseBtn @click="onClose" /> <DialogCloseBtn @click="onClose" />
<VCardItem> <VCardItem>
<VCardTitle>优先级 {{ props.pri }}</VCardTitle> <VCardTitle>优先级 {{ props.pri }}</VCardTitle>
<VRow> <VRow>
<VCol> <VCol>
<VSelect <VSelect
variant="underlined"
:key="props.pri" :key="props.pri"
v-model="selectedFilters" v-model="selectedFilters"
variant="underlined"
:items="selectFilterOptions" :items="selectFilterOptions"
@update:modelValue="filtersChanged"
chips chips
label="" label=""
multiple multiple
> @update:modelValue="filtersChanged"
</VSelect> />
</VCol> </VCol>
</VRow> </VRow>
</VCardItem> </VCardItem>
+287 -239
View File
@@ -1,209 +1,226 @@
<script lang="ts" setup> <script lang="ts" setup>
import { formatSeason } from "@/@core/utils/formatters"; import type { PropType, Ref } from 'vue'
import api from "@/api"; import { useToast } from 'vue-toast-notification'
import { doneNProgress, startNProgress } from "@/api/nprogress"; import { formatSeason } from '@/@core/utils/formatters'
import { MediaInfo, NotExistMediaInfo, Subscribe, TmdbSeason } from "@/api/types"; import api from '@/api'
import router from "@/router"; import { doneNProgress, startNProgress } from '@/api/nprogress'
import { useToast } from "vue-toast-notification"; import type { MediaInfo, NotExistMediaInfo, Subscribe, TmdbSeason } from '@/api/types'
import router from '@/router'
import noImage from '@images/no-image.jpeg'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
media: Object as PropType<MediaInfo>, media: Object as PropType<MediaInfo>,
width: String, width: String,
height: String, height: String,
}); })
// 提示框 // 提示框
const $toast = useToast(); const $toast = useToast()
// 图片加载状态 // 图片加载状态
const isImageLoaded = ref(false); const isImageLoaded = ref(false)
// 图片加载失败 // 图片加载失败
const imageLoadError = ref(false); const imageLoadError = ref(false)
// TMDB识别标志 // TMDB识别标志
const tmdbFlag = ref(true); const tmdbFlag = ref(true)
// 当前订阅状态 // 当前订阅状态
const isSubscribed = ref(false); const isSubscribed = ref(false)
// 本地存在状态 // 本地存在状态
const isExists = ref(false); const isExists = ref(false)
// 各季缺失状态:0-已存在 1-部分缺失 2-全部缺失 // 各季缺失状态:0-已存在 1-部分缺失 2-全部缺失
const seasonsNotExisted = ref<{ [key: number]: number }>({}); const seasonsNotExisted = ref<{ [key: number]: number }>({})
// 订阅季弹窗 // 订阅季弹窗
const subscribeSeasonDialog = ref(false); const subscribeSeasonDialog = ref(false)
// 季详情 // 季详情
const seasonInfos = ref<TmdbSeason[]>([]); const seasonInfos = ref<TmdbSeason[]>([])
// 选中的订阅季
const seasonsSelected = ref<TmdbSeason[]>([])
// 订阅弹窗选择的多季 // 订阅弹窗选择的多季
const subscribeSeasons = () => { function subscribeSeasons() {
subscribeSeasonDialog.value = false; subscribeSeasonDialog.value = false
seasonsSelected.value.forEach((season) => { seasonsSelected.value.forEach((season) => {
addSubscribe(season.season_number); addSubscribe(season.season_number)
}); })
}; }
// 角标颜色 // 角标颜色
const getChipColor = (type: string) => { function getChipColor(type: string) {
if (type === "电影") { if (type === '电影')
return "border-blue-500 bg-blue-600"; return 'border-blue-500 bg-blue-600'
} else if (type === "电视剧") { else if (type === '电视剧')
return " bg-indigo-500 border-indigo-600"; return ' bg-indigo-500 border-indigo-600'
} else { else
return "border-purple-600 bg-purple-600"; return 'border-purple-600 bg-purple-600'
} }
};
// 添加订阅处理 // 添加订阅处理
const handleAddSubscribe = async () => { // eslint-disable-next-line sonarjs/cognitive-complexity
if (props.media?.type == "电视剧" && props.media?.tmdb_id) { async function handleAddSubscribe() {
if (props.media?.type === '电视剧' && props.media?.tmdb_id) {
// TMDB电视剧 // TMDB电视剧
// 查询TMDB所有季信息 // 查询TMDB所有季信息
await getMediaSeasons(); await getMediaSeasons()
if (!seasonInfos.value) { if (!seasonInfos.value) {
$toast.error(`${props.media?.title} 查询剧集信息失败!`); $toast.error(`${props.media?.title} 查询剧集信息失败!`)
return;
return
} }
// 检查各季的缺失状态 // 检查各季的缺失状态
await checkSeasonsNotExists(); await checkSeasonsNotExists()
if (!tmdbFlag.value) { if (!tmdbFlag.value)
return; return
}
if (seasonInfos.value.length == 1) { if (seasonInfos.value.length === 1) {
// 只有1季 // 只有1季
if (!seasonsNotExisted.value[1]) { if (!seasonsNotExisted.value[1]) {
// 已存在 // 已存在
$toast.warning(`${props.media?.title} 媒体库中已存在!`); $toast.warning(`${props.media?.title} 媒体库中已存在!`)
} else { }
else {
// 添加订阅 // 添加订阅
addSubscribe(); addSubscribe()
} }
} else { }
else {
// 弹出季选择列表,支持多选 // 弹出季选择列表,支持多选
subscribeSeasonDialog.value = true; subscribeSeasonDialog.value = true
} }
} else if (props.media?.type == "电视剧") { }
else if (props.media?.type === '电视剧') {
// 豆瓣电视剧,只会有一季 // 豆瓣电视剧,只会有一季
let season = props.media?.season || 1; const season = props.media?.season || 1
// 检查缺失情况 // 检查缺失情况
await checkSeasonsNotExists(); await checkSeasonsNotExists()
if (!tmdbFlag.value) { if (!tmdbFlag.value)
return; return
}
if (!seasonsNotExisted.value[season]) { if (!seasonsNotExisted.value[season]) {
// 已存在 // 已存在
$toast.warning(`${props.media?.title} 媒体库中已存在!`); $toast.warning(`${props.media?.title} 媒体库中已存在!`)
} else { }
else {
// 添加订阅 // 添加订阅
addSubscribe(season); addSubscribe(season)
} }
} else { }
else {
// 电影 // 电影
let exists = await checkMovieExists(); const exists = await checkMovieExists()
if (exists) { if (exists)
$toast.warning(`${props.media?.title} 媒体库中已存在!`); $toast.warning(`${props.media?.title} 媒体库中已存在!`)
} else { else
addSubscribe(); addSubscribe()
} }
} }
};
// 调用API添加订阅,电视剧的话需要指定季 // 调用API添加订阅,电视剧的话需要指定季
const addSubscribe = async (season: number = 0) => { async function addSubscribe(season = 0) {
// 开始处理 // 开始处理
startNProgress(); startNProgress()
try { try {
const result: { [key: string]: any } = await api.post("subscribe", { const result: { [key: string]: any } = await api.post('subscribe', {
name: props.media?.title, name: props.media?.title,
type: props.media?.type, type: props.media?.type,
year: props.media?.year, year: props.media?.year,
tmdbid: props.media?.tmdb_id, tmdbid: props.media?.tmdb_id,
doubanid: props.media?.douban_id, doubanid: props.media?.douban_id,
season: season, season,
}); })
// 订阅状态 // 订阅状态
if (result.success) { if (result.success) {
// 订阅成功 // 订阅成功
isSubscribed.value = true; isSubscribed.value = true
} }
// 提示 // 提示
showSubscribeAddToast( showSubscribeAddToast(
result.success, result.success,
props.media?.title ?? "", props.media?.title ?? '',
season, season,
result.message result.message,
); )
} catch (error) { }
console.error(error); catch (error) {
console.error(error)
}
doneNProgress()
} }
doneNProgress();
};
// 弹出添加订阅提示 // 弹出添加订阅提示
const showSubscribeAddToast = ( function showSubscribeAddToast(result: boolean,
result: boolean,
title: string, title: string,
season: number, season: number,
message: string message: string) {
) => { if (season)
if (season) { title = `${title} ${formatSeason(season.toString())}`
title = `${title} ${formatSeason(season.toString())}`;
if (result)
$toast.success(`${title} 添加订阅成功!`)
else
$toast.error(`${title} 添加订阅失败:${message}`)
} }
if (result) {
$toast.success(`${title} 添加订阅成功!`);
} else {
$toast.error(`${title} 添加订阅失败:${message}`);
}
};
// 调用API取消订阅 // 调用API取消订阅
const removeSubscribe = async () => { async function removeSubscribe() {
// 开始处理 // 开始处理
startNProgress(); startNProgress()
try { try {
let mediaid = props.media?.tmdb_id const mediaid = props.media?.tmdb_id
? `tmdb:${props.media?.tmdb_id}` ? `tmdb:${props.media?.tmdb_id}`
: `douban:${props.media?.douban_id}`; : `douban:${props.media?.douban_id}`
const result: { [key: string]: any } = await api.delete( const result: { [key: string]: any } = await api.delete(
`subscribe/media/${mediaid}`, `subscribe/media/${mediaid}`,
{ {
params: { params: {
season: props.media?.season, season: props.media?.season,
}, },
} },
); )
if (result.success) { if (result.success) {
isSubscribed.value = false; isSubscribed.value = false
$toast.success(`${props.media?.title} 已取消订阅!`); $toast.success(`${props.media?.title} 已取消订阅!`)
} else {
$toast.error(`${props.media?.title} 取消订阅失败:${result.message}`);
} }
} catch (error) { else {
console.error(error); $toast.error(`${props.media?.title} 取消订阅失败:${result.message}`)
}
}
catch (error) {
console.error(error)
}
doneNProgress()
} }
doneNProgress();
};
// 查询当前媒体是否已订阅 // 查询当前媒体是否已订阅
const handleCheckSubscribe = async () => { async function handleCheckSubscribe() {
try { try {
const result = await checkSubscribe(props.media?.season); const result = await checkSubscribe(props.media?.season)
if (result) { if (result)
isSubscribed.value = true; isSubscribed.value = true
}
catch (error) {
console.error(error)
} }
} catch (error) {
console.error(error);
} }
};
// 查询当前媒体是否已存在 // 查询当前媒体是否已存在
const handleCheckExists = async () => { async function handleCheckExists() {
try { try {
const result: {[key:string]: any} = await api.get(`media/exists`, { const result: { [key: string]: any } = await api.get('media/exists', {
params: { params: {
tmdbid: props.media?.tmdb_id, tmdbid: props.media?.tmdb_id,
title: props.media?.title, title: props.media?.title,
@@ -211,143 +228,145 @@ const handleCheckExists = async () => {
season: props.media?.season, season: props.media?.season,
mtype: props.media?.type, mtype: props.media?.type,
}, },
}); })
if (result.success) {
isExists.value = true; if (result.success)
isExists.value = true
}
catch (error) {
console.error(error)
} }
} catch (error) {
console.error(error);
} }
};
// 调用API检查是否已订阅,电视剧需要指定季 // 调用API检查是否已订阅,电视剧需要指定季
const checkSubscribe = async (season: number = 0) => { async function checkSubscribe(season = 0) {
try { try {
let mediaid = props.media?.tmdb_id const mediaid = props.media?.tmdb_id
? `tmdb:${props.media?.tmdb_id}` ? `tmdb:${props.media?.tmdb_id}`
: `douban:${props.media?.douban_id}`; : `douban:${props.media?.douban_id}`
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, { const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
params: { params: {
season: season, season,
}, },
}); })
return result.id || null;
} catch (error) { return result.id || null
console.error(error); }
catch (error) {
console.error(error)
}
return null
} }
return null;
};
// 检查所有季的缺失状态 // 检查所有季的缺失状态
const checkSeasonsNotExists = async () => { async function checkSeasonsNotExists() {
// 开始处理 // 开始处理
startNProgress(); startNProgress()
try { try {
const result: NotExistMediaInfo[] = await api.post(`download/notexists`, props.media); const result: NotExistMediaInfo[] = await api.post('download/notexists', props.media)
if (result) { if (result) {
result.forEach((item) => { result.forEach((item) => {
// 0-已存在 1-部分缺失 2-全部缺失 // 0-已存在 1-部分缺失 2-全部缺失
let state = 0; let state = 0
if (item.episodes.length == 0) { if (item.episodes.length === 0)
state = 2; state = 2
} else if (item.episodes.length < item.total_episodes) { else if (item.episodes.length < item.total_episodes)
state = 1; state = 1
seasonsNotExisted.value[item.season] = state
})
} }
seasonsNotExisted.value[item.season] = state;
});
} }
} catch (error) { catch (error) {
$toast.error(`${props.media?.title}无法识别TMDB媒体信息!`); $toast.error(`${props.media?.title}无法识别TMDB媒体信息!`)
tmdbFlag.value = false; tmdbFlag.value = false
} }
// 处理完成 // 处理完成
doneNProgress(); doneNProgress()
}; }
// 检查电影是否存在 // 检查电影是否存在
const checkMovieExists = async () => { async function checkMovieExists() {
try { try {
const result: NotExistMediaInfo[] = await api.post(`download/notexists`, props.media); const result: NotExistMediaInfo[] = await api.post('download/notexists', props.media)
if (!result || result.length === 0) { return !result || result.length === 0
// 没有缺失的就是存在 }
return true; catch (error) {
console.error(error)
} }
return false;
} catch (error) {
console.error(error);
} }
};
// 查询TMDB的所有季信息 // 查询TMDB的所有季信息
const getMediaSeasons = async () => { async function getMediaSeasons() {
try { try {
seasonInfos.value = await api.get(`tmdb/${props.media?.tmdb_id}/seasons`); seasonInfos.value = await api.get(`tmdb/${props.media?.tmdb_id}/seasons`)
} catch (error) { }
console.error(error); catch (error) {
console.error(error)
}
} }
};
// 爱心订阅按钮响应 // 爱心订阅按钮响应
const handleSubscribe = () => { function handleSubscribe() {
if (isSubscribed.value) { if (isSubscribed.value)
removeSubscribe(); removeSubscribe()
} else { else
handleAddSubscribe(); handleAddSubscribe()
} }
};
// 拼装详情页链接 // 拼装详情页链接
const getDetailLink = () => { function getDetailLink() {
let link = ""; let link = ''
if (props.media?.douban_id) { if (props.media?.douban_id)
link = `https://movie.douban.com/subject/${props.media?.douban_id}/`; link = `https://movie.douban.com/subject/${props.media?.douban_id}/`
} else if (props.media?.type === "电影") { else if (props.media?.type === '电影')
link = `https://www.themoviedb.org/movie/${props.media?.tmdb_id}`; link = `https://www.themoviedb.org/movie/${props.media?.tmdb_id}`
} else if (props.media?.type === "电视剧") { else if (props.media?.type === '电视剧')
link = `https://www.themoviedb.org/tv/${props.media?.tmdb_id}`; link = `https://www.themoviedb.org/tv/${props.media?.tmdb_id}`
return link
} }
return link;
};
// 计算存在状态的颜色 // 计算存在状态的颜色
const getExistColor = (season: number) => { function getExistColor(season: number) {
let state = seasonsNotExisted.value[season]; const state = seasonsNotExisted.value[season]
if (!state) { if (!state)
return "success"; return 'success'
if (state === 1)
return 'warning'
else if (state === 2)
return 'error'
else
return 'success'
} }
if (state === 1) {
return "warning";
} else if (state === 2) {
return "error";
} else {
return "success";
}
};
// 计算存在状态的文本 // 计算存在状态的文本
const getExistText = (season: number) => { function getExistText(season: number) {
let state = seasonsNotExisted.value[season]; const state = seasonsNotExisted.value[season]
if (!state) { if (!state)
return "已存在"; return '已存在'
if (state === 1)
return '部分缺失'
else if (state === 2)
return '缺失'
else
return '已存在'
} }
if (state === 1) {
return "部分缺失";
} else if (state === 2) {
return "缺失";
} else {
return "已存在";
}
};
// 打开详情页 // 打开详情页
const openDetailWindow = () => { function openDetailWindow() {
window.open(getDetailLink(), "_blank"); window.open(getDetailLink(), '_blank')
}; }
// 开始搜索 // 开始搜索
const handleSearch = () => { function handleSearch() {
router.push({ router.push({
path: "/resource", path: '/resource',
query: { query: {
keyword: `${ keyword: `${
props.media?.tmdb_id props.media?.tmdb_id
@@ -356,34 +375,34 @@ const handleSearch = () => {
}`, }`,
type: props.media?.type, type: props.media?.type,
}, },
}); })
}; }
// 装载时检查是否已订阅 // 装载时检查是否已订阅
onBeforeMount(() => { onBeforeMount(() => {
handleCheckSubscribe(); handleCheckSubscribe()
handleCheckExists(); handleCheckExists()
}); })
// 订阅季表头 // 订阅季表头
const seasonsHeaders = [ const seasonsHeaders = [
{ title: "季", key: "title", sortable: false }, { title: '季', key: 'title', sortable: false },
{ title: "集数", key: "episodes", sortable: false }, { title: '集数', key: 'episodes', sortable: false },
{ title: "评分", key: "vote", sortable: false }, { title: '评分', key: 'vote', sortable: false },
{ title: "状态", key: "status", sortable: false }, { title: '状态', key: 'status', sortable: false },
]; ]
// 选中的订阅季
const seasonsSelected = ref<TmdbSeason[]>([]);
// 计算图片地址 // 计算图片地址
const getImgUrl = (url: string) => { const getImgUrl: Ref<string> = computed(() => {
if (imageLoadError.value)
return noImage
const url = props.media?.poster_path || noImage
// 如果地址中包含douban则使用中转代理 // 如果地址中包含douban则使用中转代理
if (url.includes("doubanio.com")) { if (url.includes('doubanio.com'))
return `${import.meta.env.VITE_API_BASE_URL}douban/img/${encodeURIComponent(url)}`; return `${import.meta.env.VITE_API_BASE_URL}douban/img/${encodeURIComponent(url)}`
}
return url; return url
}; })
</script> </script>
<template> <template>
@@ -393,28 +412,41 @@ const getImgUrl = (url: string) => {
v-bind="hover.props" v-bind="hover.props"
:height="props.height" :height="props.height"
:width="props.width" :width="props.width"
:loading="!isImageLoaded"
class="outline-none shadow ring-gray-500" class="outline-none shadow ring-gray-500"
:class="{ :class="{
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering, 'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
'ring-1': isImageLoaded, 'ring-1': isImageLoaded,
}" }"
> >
<template #loader="{ isActive }">
<v-progress-linear
:active="isActive"
color="deep-purple"
height="4"
indeterminate
/>
</template>
<VImg <VImg
aspect-ratio="2/3" aspect-ratio="2/3"
:src="getImgUrl(props.media?.poster_path || '')" :src="getImgUrl"
class="object-cover aspect-w-2 aspect-h-3" class="object-cover aspect-w-2 aspect-h-3"
:class="hover.isHovering ? 'on-hover' : ''" :class="hover.isHovering ? 'on-hover' : ''"
cover
@load="isImageLoaded = true" @load="isImageLoaded = true"
@error="imageLoadError = true" @error="imageLoadError = true"
cover
> >
<template #placeholder> <template #placeholder>
<div class="relative animate-pulse bg-gray-300 w-full"> <div class="d-flex align-center justify-center fill-height">
<div class="w-full h-full"></div> <v-progress-circular
color="grey-lighten-4"
indeterminate
/>
</div> </div>
</template> </template>
<!-- 类型角标 --> <!-- 类型角标 -->
<VChip <VChip
v-show="isImageLoaded"
variant="elevated" variant="elevated"
size="small" size="small"
:class="getChipColor(props.media?.type || '')" :class="getChipColor(props.media?.type || '')"
@@ -426,9 +458,9 @@ const getImgUrl = (url: string) => {
<ExistIcon v-if="isExists" /> <ExistIcon v-if="isExists" />
<!-- 评分角标 --> <!-- 评分角标 -->
<VChip <VChip
v-if="isImageLoaded && props.media?.vote_average && !isExists"
variant="elevated" variant="elevated"
size="small" size="small"
v-if="props.media?.vote_average && !isExists"
:class="getChipColor('rating')" :class="getChipColor('rating')"
class="absolute right-2 top-2 bg-opacity-80 shadow-md text-white font-bold" class="absolute right-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
> >
@@ -436,21 +468,23 @@ const getImgUrl = (url: string) => {
</VChip> </VChip>
<!-- 详情 --> <!-- 详情 -->
<VCardText <VCardText
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
v-show="hover.isHovering || imageLoadError" v-show="hover.isHovering || imageLoadError"
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
@click.stop="openDetailWindow" @click.stop="openDetailWindow"
> >
<span class="font-bold">{{ props.media?.year }}</span> <span class="font-bold">{{ props.media?.year }}</span>
<h1 <h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ..."
>
{{ props.media?.title }} {{ props.media?.title }}
</h1> </h1>
<p class="leading-4 line-clamp-4 overflow-hidden text-ellipsis ..."> <p class="leading-4 line-clamp-4 overflow-hidden text-ellipsis ...">
{{ props.media?.overview }} {{ props.media?.overview }}
</p> </p>
<div class="flex align-center justify-between"> <div class="flex align-center justify-between">
<IconBtn icon="mdi-magnify" color="white" @click.stop="handleSearch" /> <IconBtn
icon="mdi-magnify"
color="white"
@click.stop="handleSearch"
/>
<IconBtn <IconBtn
icon="mdi-heart" icon="mdi-heart"
:color="isSubscribed ? 'error' : 'white'" :color="isSubscribed ? 'error' : 'white'"
@@ -484,39 +518,53 @@ const getImgUrl = (url: string) => {
height="auto" height="auto"
> >
<template #item.title="{ item }"> <template #item.title="{ item }">
<span class="d-block whitespace-nowrap" <span class="d-block whitespace-nowrap"> {{ item.raw.season_number }}
> {{ item.raw.season_number }} </span>
</span></template </template>
>
<template #item.episodes="{ item }"> <template #item.episodes="{ item }">
<VChip variant="outlined" size="small">{{ item.raw.episode_count }}</VChip> <VChip
variant="outlined"
size="small"
>
{{ item.raw.episode_count }}
</VChip>
</template> </template>
<template #item.vote="{ item }"> <template #item.vote="{ item }">
{{ item.raw.vote_average }} {{ item.raw.vote_average }}
</template> </template>
<template #item.status="{ item }"> <template #item.status="{ item }">
<VChip <VChip
:color="getExistColor(item.raw.season_number)"
v-if="seasonsNotExisted" v-if="seasonsNotExisted"
:color="getExistColor(item.raw.season_number)"
flat flat
size="small" size="small"
>{{ getExistText(item.raw.season_number) }}</VChip
> >
{{ getExistText(item.raw.season_number) }}
</VChip>
</template> </template>
<template #no-data> 没有数据 </template> <template #no-data>
<template #bottom></template> 没有数据
</template>
<template #bottom />
</VDataTable> </VDataTable>
</VCardText> </VCardText>
<VCardActions> <VCardActions>
<VBtn @click="subscribeSeasonDialog = false"> 取消 </VBtn> <VBtn @click="subscribeSeasonDialog = false">
取消
</VBtn>
<VSpacer /> <VSpacer />
<VBtn @click="subscribeSeasons" @keydown.enter="subscribeSeasons"> 确定 </VBtn> <VBtn
@click="subscribeSeasons"
@keydown.enter="subscribeSeasons"
>
确定
</VBtn>
</VCardActions> </VCardActions>
</VCard> </VCard>
</VDialog> </VDialog>
</template> </template>
<style type="scss"> <style lang="scss">
.on-hover img { .on-hover img {
@apply brightness-50; @apply brightness-50;
} }
+46 -25
View File
@@ -1,47 +1,63 @@
<script lang="ts" setup> <script lang="ts" setup>
import api from "@/api"; import { useToast } from 'vue-toast-notification'
import { Plugin } from "@/api/types"; import api from '@/api'
import { useToast } from "vue-toast-notification"; import type { Plugin } from '@/api/types'
// 定义触发的自定义事件
const emit = defineEmits(["install"]);
// 提示框
const $toast = useToast();
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
plugin: Object as PropType<Plugin>, plugin: Object as PropType<Plugin>,
width: String, width: String,
height: String, height: String,
}); })
// 定义触发的自定义事件
const emit = defineEmits(['install'])
// 提示框
const $toast = useToast()
// 安装插件 // 安装插件
const installPlugin = async () => { async function installPlugin() {
try { try {
const result: { [key: string]: any } = await api.get( const result: { [key: string]: any } = await api.get(
`plugin/install/${props.plugin?.id}` `plugin/install/${props.plugin?.id}`,
); )
if (result.success) { if (result.success) {
$toast.success(`插件 ${props.plugin?.plugin_name} 安装成功!`); $toast.success(`插件 ${props.plugin?.plugin_name} 安装成功!`)
// 通知父组件刷新 // 通知父组件刷新
emit("install"); emit('install')
} else { }
$toast.error(`插件 ${props.plugin?.plugin_name} 安装失败:${result.message}}`); else {
$toast.error(`插件 ${props.plugin?.plugin_name} 安装失败:${result.message}}`)
}
}
catch (error) {
console.error(error)
} }
} catch (error) {
console.error(error);
} }
};
</script> </script>
<template> <template>
<VCard :width="props.width" :height="props.height" @click="installPlugin"> <VCard
:width="props.width"
:height="props.height"
@click="installPlugin"
>
<div <div
class="relative pa-4 text-center card-cover-blurred" class="relative pa-4 text-center card-cover-blurred"
:style="{ background: `${props.plugin?.plugin_color}` }" :style="{ background: `${props.plugin?.plugin_color}` }"
> >
<VAvatar size="128" class="shadow"> <VAvatar
<VImg :src="`/plugin/${props.plugin?.plugin_icon}`" aspect-ratio="4/3" cover /> size="128"
class="shadow"
>
<VImg
:src="`/plugin/${props.plugin?.plugin_icon}`"
aspect-ratio="4/3"
cover
/>
</VAvatar> </VAvatar>
</div> </div>
<VCardTitle>{{ props.plugin?.plugin_name }}</VCardTitle> <VCardTitle>{{ props.plugin?.plugin_name }}</VCardTitle>
@@ -50,13 +66,18 @@ const installPlugin = async () => {
{{ props.plugin?.plugin_desc }} {{ props.plugin?.plugin_desc }}
</VCardText> </VCardText>
<VCardText> <VCardText>
作者<a :href="props.plugin?.author_url" target="_blank" @click.stop> 作者<a
:href="props.plugin?.author_url"
target="_blank"
@click.stop
>
{{ props.plugin?.plugin_author }} {{ props.plugin?.plugin_author }}
</a> </a>
</VCardText> </VCardText>
</VCard> </VCard>
</template> </template>
<style type="scss" scoped>
<style lang="scss" scoped>
.card-cover-blurred::before { .card-cover-blurred::before {
position: absolute; position: absolute;
/* stylelint-disable-next-line property-no-vendor-prefix */ /* stylelint-disable-next-line property-no-vendor-prefix */
+48 -33
View File
@@ -1,62 +1,66 @@
<script lang="ts" setup> <script lang="ts" setup>
import api from "@/api"; import { useToast } from 'vue-toast-notification'
import { Plugin } from "@/api/types"; import api from '@/api'
import { useToast } from "vue-toast-notification"; import type { Plugin } from '@/api/types'
// 定义触发的自定义事件
const emit = defineEmits(["remove"]);
// 提示框
const $toast = useToast();
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
plugin: Object as PropType<Plugin>, plugin: Object as PropType<Plugin>,
width: String, width: String,
height: String, height: String,
}); })
// 定义触发的自定义事件
const emit = defineEmits(['remove'])
// 提示框
const $toast = useToast()
// 本身是否可见 // 本身是否可见
const isVisible = ref(true); const isVisible = ref(true)
// 卸载插件 // 卸载插件
const uninstallPlugin = async () => { async function uninstallPlugin() {
try { try {
const result: { [key: string]: any } = await api.delete(`plugin/${props.plugin?.id}`); const result: { [key: string]: any } = await api.delete(`plugin/${props.plugin?.id}`)
if (result.success) { if (result.success) {
$toast.success(`插件 ${props.plugin?.plugin_name} 已卸载`); $toast.success(`插件 ${props.plugin?.plugin_name} 已卸载`)
// 通知父组件刷新 // 通知父组件刷新
emit("remove"); emit('remove')
} else { }
$toast.error(`插件 ${props.plugin?.plugin_name} 卸载失败:${result.message}}`); else {
$toast.error(`插件 ${props.plugin?.plugin_name} 卸载失败:${result.message}}`)
}
}
catch (error) {
console.error(error)
} }
} catch (error) {
console.error(error);
} }
};
// 显示插件详情 // 显示插件详情
const showPluginInfo = () => {}; function showPluginInfo() {}
// 弹出菜单 // 弹出菜单
const dropdownItems = ref([ const dropdownItems = ref([
{ {
title: "卸载", title: '卸载',
value: 1, value: 1,
props: { props: {
prependIcon: "mdi-trash-can-outline", prependIcon: 'mdi-trash-can-outline',
color: "error", color: 'error',
click: uninstallPlugin, click: uninstallPlugin,
}, },
}, },
]); ])
</script> </script>
<template> <template>
<VCard <VCard
v-if="isVisible"
:width="props.width" :width="props.width"
:height="props.height" :height="props.height"
@click="showPluginInfo" @click="showPluginInfo"
v-if="isVisible"
> >
<div <div
class="relative pa-4 text-center card-cover-blurred" class="relative pa-4 text-center card-cover-blurred"
@@ -65,26 +69,36 @@ const dropdownItems = ref([
<div class="me-n3 absolute top-0 right-3"> <div class="me-n3 absolute top-0 right-3">
<IconBtn> <IconBtn>
<VIcon icon="mdi-dots-vertical" /> <VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click> <VMenu
activator="parent"
close-on-content-click
>
<VList> <VList>
<VListItem <VListItem
v-for="(item, i) in dropdownItems" v-for="(item, i) in dropdownItems"
:key="i"
variant="plain" variant="plain"
:base-color="item.props.color" :base-color="item.props.color"
:key="i"
@click="item.props.click" @click="item.props.click"
> >
<template #prepend> <template #prepend>
<VIcon :icon="item.props.prependIcon"></VIcon> <VIcon :icon="item.props.prependIcon" />
</template> </template>
<VListItemTitle v-text="item.title"></VListItemTitle> <VListItemTitle v-text="item.title" />
</VListItem> </VListItem>
</VList> </VList>
</VMenu> </VMenu>
</IconBtn> </IconBtn>
</div> </div>
<VAvatar size="128" class="shadow"> <VAvatar
<VImg :src="`/plugin/${props.plugin?.plugin_icon}`" aspect-ratio="4/3" cover /> size="128"
class="shadow"
>
<VImg
:src="`/plugin/${props.plugin?.plugin_icon}`"
aspect-ratio="4/3"
cover
/>
</VAvatar> </VAvatar>
</div> </div>
<VCardItem class="py-2"> <VCardItem class="py-2">
@@ -95,7 +109,8 @@ const dropdownItems = ref([
</VCardText> </VCardText>
</VCard> </VCard>
</template> </template>
<style type="scss" scoped>
<style lang="scss" scoped>
.card-cover-blurred::before { .card-cover-blurred::before {
position: absolute; position: absolute;
/* stylelint-disable-next-line property-no-vendor-prefix */ /* stylelint-disable-next-line property-no-vendor-prefix */
+245 -125
View File
@@ -1,183 +1,207 @@
<script lang="ts" setup> <script lang="ts" setup>
import { numberValidator, requiredValidator } from "@/@validators"; import type { PropType } from 'vue'
import api from "@/api"; import { useToast } from 'vue-toast-notification'
import { Site } from "@/api/types"; import { numberValidator, requiredValidator } from '@/@validators'
import ExistIcon from "@core/components/ExistIcon.vue"; import api from '@/api'
import { useToast } from "vue-toast-notification"; import type { Site } from '@/api/types'
import ExistIcon from '@core/components/ExistIcon.vue'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
site: Object as PropType<Site>, site: Object as PropType<Site>,
width: String, width: String,
height: String, height: String,
}); })
// 密码输入 // 密码输入
const isPasswordVisible = ref(false); const isPasswordVisible = ref(false)
// 图标 // 图标
const siteIcon = ref<string>(""); const siteIcon = ref<string>('')
// 提示框 // 提示框
const $toast = useToast(); const $toast = useToast()
// 测试按钮文字 // 测试按钮文字
const testButtonText = ref("测试"); const testButtonText = ref('测试')
// 测试按钮可用性 // 测试按钮可用性
const testButtonDisable = ref(false); const testButtonDisable = ref(false)
// 更新按钮文字 // 更新按钮文字
const updateButtonText = ref("更新"); const updateButtonText = ref('更新')
// 更新按钮可用性 // 更新按钮可用性
const updateButtonDisable = ref(false); const updateButtonDisable = ref(false)
// 更新站点Cookie UA弹窗 // 更新站点Cookie UA弹窗
const siteCookieDialog = ref(false); const siteCookieDialog = ref(false)
// 站点编辑弹窗 // 站点编辑弹窗
const siteInfoDialog = ref(false); const siteInfoDialog = ref(false)
// 用户名密码表单 // 用户名密码表单
const userPwForm = ref({ const userPwForm = ref({
username: "", username: '',
password: "", password: '',
}); })
// 查询站点图标 // 查询站点图标
const getSiteIcon = async () => { async function getSiteIcon() {
try { try {
siteIcon.value = (await api.get("site/icon/" + props.site?.id)).data.icon; siteIcon.value = (await api.get(`site/icon/${props.site?.id}`)).data.icon
} catch (error) { }
console.error(error); catch (error) {
console.error(error)
}
} }
};
// 测试站点连通性 // 测试站点连通性
const testSite = async () => { async function testSite() {
try { try {
testButtonText.value = "测试中 ..."; testButtonText.value = '测试中 ...'
testButtonDisable.value = true; testButtonDisable.value = true
const result: { [key: string]: any } = await api.get("site/test/" + props.site?.id);
if (result.success) { const result: { [key: string]: any } = await api.get(`site/test/${props.site?.id}`)
$toast.success(`${props.site?.name} 连通性测试成功,可正常使用!`); if (result.success)
} else { $toast.success(`${props.site?.name} 连通性测试成功,可正常使用!`)
$toast.error(`${props.site?.name} 连通性测试失败:${result.message}`); else
$toast.error(`${props.site?.name} 连通性测试失败:${result.message}`)
testButtonText.value = '测试'
testButtonDisable.value = false
}
catch (error) {
console.error(error)
} }
testButtonText.value = "测试";
testButtonDisable.value = false;
} catch (error) {
console.error(error);
} }
};
// 打开更新站点Cookie UA弹窗 // 打开更新站点Cookie UA弹窗
const handleSiteUpdate = async () => { async function handleSiteUpdate() {
siteCookieDialog.value = true; siteCookieDialog.value = true
};
// 打开站点编辑弹窗
const handleSiteInfo = async () => {
siteInfoDialog.value = true;
};
// 调用API,更新站点Cookie UA
const updateSiteCookie = async () => {
try {
if (!userPwForm.value.username || !userPwForm.value.password) {
return;
} }
// 打开站点编辑弹窗
async function handleSiteInfo() {
siteInfoDialog.value = true
}
// 调用API,更新站点Cookie UA
async function updateSiteCookie() {
try {
if (!userPwForm.value.username || !userPwForm.value.password)
return
// 更新按钮状态 // 更新按钮状态
siteCookieDialog.value = false; siteCookieDialog.value = false
updateButtonText.value = "更新中 ..."; updateButtonText.value = '更新中 ...'
updateButtonDisable.value = true; updateButtonDisable.value = true
const result: { [key: string]: any } = await api.get( const result: { [key: string]: any } = await api.get(
"site/cookie/" + props.site?.id, `site/cookie/${props.site?.id}`,
{ {
params: { params: {
username: userPwForm.value.username, username: userPwForm.value.username,
password: userPwForm.value.password, password: userPwForm.value.password,
}, },
} },
); )
if (result.success) {
$toast.success(`${props.site?.name} 更新Cookie & UA 成功!`);
} else {
$toast.error(`${props.site?.name} 更新失败:${result.message}`);
}
updateButtonText.value = "更新";
updateButtonDisable.value = false;
} catch (error) {
console.error(error);
}
};
// 调用API更新站点信息 if (result.success)
const updateSiteInfo = async () => { $toast.success(`${props.site?.name} 更新Cookie & UA 成功!`)
try { else
// 更新按钮状态 $toast.error(`${props.site?.name} 更新失败:${result.message}`)
siteInfoDialog.value = false;
const result: { [key: string]: any } = await api.put("site", siteForm); updateButtonText.value = '更新'
if (result.success) { updateButtonDisable.value = false
$toast.success(`${props.site?.name} 更新成功!`); }
} else { catch (error) {
$toast.error(`${props.site?.name} 更新失败:${result.message}`); console.error(error)
} }
} catch (error) {
$toast.error(`${props.site?.name} 更新失败!`);
console.error(error);
} }
};
// 站点编辑表单数据 // 站点编辑表单数据
const siteForm = reactive({ const siteForm = reactive({
// ID // ID
id: props.site?.id, id: props.site?.id,
// 站点名称 // 站点名称
name: props.site?.name, name: props.site?.name,
// 站点主域名Key // 站点主域名Key
domain: props.site?.domain, domain: props.site?.domain,
// 站点地址 // 站点地址
url: props.site?.url, url: props.site?.url,
// 站点优先级 // 站点优先级
pri: props.site?.pri, pri: props.site?.pri,
// RSS地址 // RSS地址
rss: props.site?.rss, rss: props.site?.rss,
// Cookie // Cookie
cookie: props.site?.cookie, cookie: props.site?.cookie,
// User-Agent // User-Agent
ua: props.site?.ua, ua: props.site?.ua,
// 是否使用代理 // 是否使用代理
proxy: props.site?.proxy ? true : false, proxy: !!props.site?.proxy,
// 过滤规则 // 过滤规则
filter: props.site?.filter, filter: props.site?.filter,
// 是否演染 // 是否演染
render: props.site?.render ? true : false, render: !!props.site?.render,
// 是否公开站点 // 是否公开站点
public: props.site?.public, public: props.site?.public,
// 备注 // 备注
note: props.site?.note, note: props.site?.note,
// 流控单位周期 // 流控单位周期
limit_interval: props.site?.limit_interval, limit_interval: props.site?.limit_interval,
// 流控次数 // 流控次数
limit_count: props.site?.limit_count, limit_count: props.site?.limit_count,
// 流控间隔 // 流控间隔
limit_seconds: props.site?.limit_seconds, limit_seconds: props.site?.limit_seconds,
// 是否启用 // 是否启用
is_active: props.site?.is_active, is_active: props.site?.is_active,
}); })
// 调用API更新站点信息
async function updateSiteInfo() {
try {
// 更新按钮状态
siteInfoDialog.value = false
const result: { [key: string]: any } = await api.put('site', siteForm)
if (result.success)
$toast.success(`${props.site?.name} 更新成功!`)
else
$toast.error(`${props.site?.name} 更新失败:${result.message}`)
}
catch (error) {
$toast.error(`${props.site?.name} 更新失败!`)
console.error(error)
}
}
// 状态下拉项 // 状态下拉项
const statusItems = [ const statusItems = [
{ title: "启用", value: true }, { title: '启用', value: true },
{ title: "停用", value: false }, { title: '停用', value: false },
]; ]
// 装载时查询站点图标 // 装载时查询站点图标
onMounted(() => { onMounted(() => {
getSiteIcon(); getSiteIcon()
}); })
</script> </script>
<template> <template>
@@ -189,37 +213,70 @@ onMounted(() => {
@click="siteInfoDialog = true" @click="siteInfoDialog = true"
> >
<template #image> <template #image>
<VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0"> <VAvatar
class="absolute right-2 bottom-2 rounded"
variant="flat"
rounded="0"
>
<VImg :src="siteIcon" /> <VImg :src="siteIcon" />
</VAvatar> </VAvatar>
</template> </template>
<VCardItem> <VCardItem>
<VCardTitle class="font-bold">{{ props.site?.name }}</VCardTitle> <VCardTitle class="font-bold">
{{ props.site?.name }}
</VCardTitle>
<VCardSubtitle>{{ props.site?.url }}</VCardSubtitle> <VCardSubtitle>{{ props.site?.url }}</VCardSubtitle>
</VCardItem> </VCardItem>
<ExistIcon v-if="siteForm.is_active" /> <ExistIcon v-if="siteForm.is_active" />
<VCardText class="py-2"> <VCardText class="py-2">
<VTooltip text="浏览器仿真" v-if="siteForm.render"> <VTooltip
v-if="siteForm.render"
text="浏览器仿真"
>
<template #activator="{ props }"> <template #activator="{ props }">
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-apple-safari" /> <VIcon
color="primary"
class="me-2"
v-bind="props"
icon="mdi-apple-safari"
/>
</template> </template>
</VTooltip> </VTooltip>
<VTooltip text="代理" v-if="siteForm.proxy"> <VTooltip
v-if="siteForm.proxy"
text="代理"
>
<template #activator="{ props }"> <template #activator="{ props }">
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-network-outline" /> <VIcon
color="primary"
class="me-2"
v-bind="props"
icon="mdi-network-outline"
/>
</template> </template>
</VTooltip> </VTooltip>
<VTooltip text="流控" v-if="siteForm.limit_interval"> <VTooltip
v-if="siteForm.limit_interval"
text="流控"
>
<template #activator="{ props }"> <template #activator="{ props }">
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-speedometer" /> <VIcon
color="primary"
class="me-2"
v-bind="props"
icon="mdi-speedometer"
/>
</template> </template>
</VTooltip> </VTooltip>
<VTooltip text="过滤" v-if="siteForm.filter"> <VTooltip
v-if="siteForm.filter"
text="过滤"
>
<template #activator="{ props }"> <template #activator="{ props }">
<VIcon <VIcon
color="primary" color="primary"
@@ -233,44 +290,56 @@ onMounted(() => {
<VCardActions> <VCardActions>
<VBtn <VBtn
@click.stop="handleSiteUpdate"
:disabled="updateButtonDisable"
v-if="!props.site?.public" v-if="!props.site?.public"
:disabled="updateButtonDisable"
@click.stop="handleSiteUpdate"
> >
<template #prepend> <template #prepend>
<VIcon icon="mdi-refresh"></VIcon> <VIcon icon="mdi-refresh" />
</template> </template>
{{ updateButtonText }} {{ updateButtonText }}
</VBtn> </VBtn>
<VBtn @click.stop="handleSiteInfo"> <VBtn @click.stop="handleSiteInfo">
<template #prepend> <template #prepend>
<VIcon icon="mdi-square-edit-outline"></VIcon> <VIcon icon="mdi-square-edit-outline" />
</template> </template>
编辑 编辑
</VBtn> </VBtn>
<VBtn @click.stop="testSite" :disabled="testButtonDisable"> <VBtn
:disabled="testButtonDisable"
@click.stop="testSite"
>
<template #prepend> <template #prepend>
<VIcon icon="mdi-network-outline"></VIcon> <VIcon icon="mdi-network-outline" />
</template> </template>
{{ testButtonText }} {{ testButtonText }}
</VBtn> </VBtn>
</VCardActions> </VCardActions>
</VCard> </VCard>
<!-- 更新站点Cookie & UA弹窗 --> <!-- 更新站点Cookie & UA弹窗 -->
<VDialog v-model="siteCookieDialog" max-width="600"> <VDialog
v-model="siteCookieDialog"
max-width="600"
>
<!-- Dialog Content --> <!-- Dialog Content -->
<VCard title="更新站点Cookie & UA"> <VCard title="更新站点Cookie & UA">
<VCardText> <VCardText>
<VForm @submit.prevent="() => {}"> <VForm @submit.prevent="() => {}">
<VRow> <VRow>
<VCol cols="12" md="6"> <VCol
cols="12"
md="6"
>
<VTextField <VTextField
v-model="userPwForm.username" v-model="userPwForm.username"
label="用户名" label="用户名"
:rules="[requiredValidator]" :rules="[requiredValidator]"
/> />
</VCol> </VCol>
<VCol cols="12" md="6"> <VCol
cols="12"
md="6"
>
<VTextField <VTextField
v-model="userPwForm.password" v-model="userPwForm.password"
label="密码" label="密码"
@@ -278,8 +347,8 @@ onMounted(() => {
:append-inner-icon=" :append-inner-icon="
isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline' isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'
" "
@click:append-inner="isPasswordVisible = !isPasswordVisible"
:rules="[requiredValidator]" :rules="[requiredValidator]"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
@keydown.enter="updateSiteCookie" @keydown.enter="updateSiteCookie"
/> />
</VCol> </VCol>
@@ -289,25 +358,38 @@ onMounted(() => {
<VCardActions> <VCardActions>
<VSpacer /> <VSpacer />
<VBtn @click="updateSiteCookie"> 开始更新 </VBtn> <VBtn @click="updateSiteCookie">
开始更新
</VBtn>
</VCardActions> </VCardActions>
</VCard> </VCard>
</VDialog> </VDialog>
<!-- 站点编辑弹窗 --> <!-- 站点编辑弹窗 -->
<VDialog v-model="siteInfoDialog" max-width="1000" persistent scrollable> <VDialog
v-model="siteInfoDialog"
max-width="1000"
persistent
scrollable
>
<!-- Dialog Content --> <!-- Dialog Content -->
<VCard :title="`编辑站点 - ${props.site?.name}`"> <VCard :title="`编辑站点 - ${props.site?.name}`">
<VCardText> <VCardText>
<VForm @submit.prevent="() => {}"> <VForm @submit.prevent="() => {}">
<VRow> <VRow>
<VCol cols="12" md="6"> <VCol
cols="12"
md="6"
>
<VTextField <VTextField
v-model="siteForm.url" v-model="siteForm.url"
label="站点地址" label="站点地址"
:rules="[requiredValidator]" :rules="[requiredValidator]"
/> />
</VCol> </VCol>
<VCol cols="12" md="3"> <VCol
cols="12"
md="3"
>
<VSelect <VSelect
v-model="siteForm.pri" v-model="siteForm.pri"
label="优先级" label="优先级"
@@ -315,34 +397,56 @@ onMounted(() => {
:rules="[requiredValidator]" :rules="[requiredValidator]"
/> />
</VCol> </VCol>
<VCol cols="12" md="3"> <VCol
<VSelect v-model="siteForm.is_active" :items="statusItems" label="状态" /> cols="12"
md="3"
>
<VSelect
v-model="siteForm.is_active"
:items="statusItems"
label="状态"
/>
</VCol> </VCol>
</VRow> </VRow>
<VRow> <VRow>
<VCol cols="12"> <VCol cols="12">
<VTextarea v-model="siteForm.cookie" label="站点Cookie" /> <VTextarea
v-model="siteForm.cookie"
label="站点Cookie"
/>
</VCol> </VCol>
<VCol cols="12"> <VCol cols="12">
<VTextField v-model="siteForm.ua" label="站点User-Agent" /> <VTextField
v-model="siteForm.ua"
label="站点User-Agent"
/>
</VCol> </VCol>
</VRow> </VRow>
<VRow> <VRow>
<VCol cols="12" md="4"> <VCol
cols="12"
md="4"
>
<VTextField <VTextField
v-model="siteForm.limit_interval" v-model="siteForm.limit_interval"
label="单位周期(秒)" label="单位周期(秒)"
:rules="[numberValidator]" :rules="[numberValidator]"
/> />
</VCol> </VCol>
<VCol cols="12" md="4"> <VCol
cols="12"
md="4"
>
<VTextField <VTextField
v-model="siteForm.limit_seconds" v-model="siteForm.limit_seconds"
label="访问次数" label="访问次数"
:rules="[numberValidator]" :rules="[numberValidator]"
/> />
</VCol> </VCol>
<VCol cols="12" md="4"> <VCol
cols="12"
md="4"
>
<VTextField <VTextField
v-model="siteForm.limit_seconds" v-model="siteForm.limit_seconds"
label="访问间隔(秒)" label="访问间隔(秒)"
@@ -351,20 +455,36 @@ onMounted(() => {
</VCol> </VCol>
</VRow> </VRow>
<VRow> <VRow>
<VCol cols="12" md="6"> <VCol
<VSwitch v-model="siteForm.proxy" label="代理" /> cols="12"
md="6"
>
<VSwitch
v-model="siteForm.proxy"
label="代理"
/>
</VCol> </VCol>
<VCol cols="12" md="6"> <VCol
<VSwitch v-model="siteForm.render" label="仿真" /> cols="12"
md="6"
>
<VSwitch
v-model="siteForm.render"
label="仿真"
/>
</VCol> </VCol>
</VRow> </VRow>
</VForm> </VForm>
</VCardText> </VCardText>
<VCardActions> <VCardActions>
<VBtn @click="siteInfoDialog = false"> 取消 </VBtn> <VBtn @click="siteInfoDialog = false">
取消
</VBtn>
<VSpacer /> <VSpacer />
<VBtn @click="updateSiteInfo"> 确定 </VBtn> <VBtn @click="updateSiteInfo">
确定
</VBtn>
</VCardActions> </VCardActions>
</VCard> </VCard>
</VDialog> </VDialog>
+209 -142
View File
@@ -1,209 +1,225 @@
<script lang="ts" setup> <script lang="ts" setup>
import { calculateTimeDifference } from "@/@core/utils"; import { useToast } from 'vue-toast-notification'
import { formatSeason } from "@/@core/utils/formatters"; import { calculateTimeDifference } from '@/@core/utils'
import { numberValidator } from "@/@validators"; import { formatSeason } from '@/@core/utils/formatters'
import api from "@/api"; import { numberValidator } from '@/@validators'
import { Site, Subscribe } from "@/api/types"; import api from '@/api'
import { useToast } from "vue-toast-notification"; import type { Site, Subscribe } from '@/api/types'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
media: Object as PropType<Subscribe>, media: Object as PropType<Subscribe>,
}); })
// 提示框 // 提示框
const $toast = useToast(); const $toast = useToast()
// 是否显示卡片 // 是否显示卡片
const cardState = ref(true); const cardState = ref(true)
// 图片是否加载完成 // 图片是否加载完成
const imageLoaded = ref(false); const imageLoaded = ref(false)
// 订阅弹窗 // 订阅弹窗
const subscribeInfoDialog = ref(false); const subscribeInfoDialog = ref(false)
// 站点数据列表 // 站点数据列表
const siteList = ref<Site[]>([]); const siteList = ref<Site[]>([])
// 站点选择下载框 // 站点选择下载框
const selectSitesOptions = ref<{ [key: number]: string }[]>([]); const selectSitesOptions = ref<{ [key: number]: string }[]>([])
// 订阅编辑表单
const subscribeForm = reactive({
id: props.media?.id,
// 搜索关键字
keyword: props.media?.keyword,
// 过滤规则
filter: props.media?.filter,
// 包含
include: props.media?.include,
// 排除
exclude: props.media?.exclude,
// 总集数
total_episode: props.media?.total_episode,
// 开始集数
start_episode: props.media?.start_episode,
// 订阅站点
sites: props.media?.sites,
})
// 上一次更新时间 // 上一次更新时间
const lastUpdateText = ref( const lastUpdateText = ref(
`${ `${
props.media?.last_update props.media?.last_update
? `${calculateTimeDifference(props.media?.last_update || "")}` ? `${calculateTimeDifference(props.media?.last_update || '')}`
: "" : ''
}` }`,
); )
// 图片加载完成响应 // 图片加载完成响应
const imageLoadHandler = () => { function imageLoadHandler() {
imageLoaded.value = true; imageLoaded.value = true
}; }
// 根据 type 返回不同的图标 // 根据 type 返回不同的图标
const getIcon = () => { function getIcon() {
if (props.media?.type === "电影") { if (props.media?.type === '电影')
return "mdi-movie"; return 'mdi-movie'
} else if (props.media?.type === "电视剧") { else if (props.media?.type === '电视剧')
return "mdi-television-classic"; return 'mdi-television-classic'
} else { else
return "mdi-help-circle"; return 'mdi-help-circle'
} }
};
// 计算百分比 // 计算百分比
const getPercentage = () => { function getPercentage() {
if (props.media?.total_episode === 0) { if (props.media?.total_episode === 0)
return 0; return 0
}
return Math.round( return Math.round(
(((props.media?.total_episode || 0) - (props.media?.lack_episode || 0)) / (((props.media?.total_episode || 0) - (props.media?.lack_episode || 0))
(props.media?.total_episode || 1)) * / (props.media?.total_episode || 1))
100 * 100,
); )
}; }
// 计算文本颜色 // 计算文本颜色
const getTextColor = () => { function getTextColor() {
return imageLoaded.value ? "white" : ""; return imageLoaded.value ? 'white' : ''
}; }
// 计算文本类 // 计算文本类
const getTextClass = () => { function getTextClass() {
return imageLoaded.value ? "text-white" : ""; return imageLoaded.value ? 'text-white' : ''
}; }
// 删除订阅 // 删除订阅
const removeSubscribe = async () => { async function removeSubscribe() {
try { try {
const result: { [key: string]: any } = await api.delete( const result: { [key: string]: any } = await api.delete(
`subscribe/${props.media?.id}` `subscribe/${props.media?.id}`,
); )
if (result.success) {
cardState.value = false; if (result.success)
cardState.value = false
}
catch (e) {
console.log(e)
} }
} catch (e) {
console.log(e);
} }
};
// 搜索订阅 // 搜索订阅
const searchSubscribe = async () => { async function searchSubscribe() {
try { try {
const result: { [key: string]: any } = await api.get( const result: { [key: string]: any } = await api.get(
`subscribe/search/${props.media?.id}` `subscribe/search/${props.media?.id}`,
); )
// 提示 // 提示
if (result.success) { if (result.success)
$toast.success(`${props.media?.name} 提交搜索请求成功!`); $toast.success(`${props.media?.name} 提交搜索请求成功!`)
}
catch (e) {
console.log(e)
} }
} catch (e) {
console.log(e);
} }
};
// 调用API修改订阅 // 调用API修改订阅
const updateSubscribeInfo = async () => { async function updateSubscribeInfo() {
subscribeInfoDialog.value = false; subscribeInfoDialog.value = false
try { try {
const result: { [key: string]: any } = await api.put(`subscribe`, subscribeForm); const result: { [key: string]: any } = await api.put('subscribe', subscribeForm)
// 提示 // 提示
if (result.success) { if (result.success)
$toast.success(`${props.media?.name} 更新成功!`); $toast.success(`${props.media?.name} 更新成功!`)
} else { else
$toast.error(`${props.media?.name} 更新失败:${result.message}`); $toast.error(`${props.media?.name} 更新失败:${result.message}`)
}
catch (e) {
console.log(e)
} }
} catch (e) {
console.log(e);
} }
};
// 获取站点列表数据 // 获取站点列表数据
const loadSites = async () => { async function loadSites() {
try { try {
const data: Site[] = await api.get("site"); const data: Site[] = await api.get('site')
// 过滤站点,只有启用的站点才显示 // 过滤站点,只有启用的站点才显示
siteList.value = data.filter((item) => item.is_active); siteList.value = data.filter(item => item.is_active)
} catch (error) { }
console.error(error); catch (error) {
console.error(error)
}
} }
};
// 获取站点列表选择框数据 // 获取站点列表选择框数据
const getSiteList = async () => { async function getSiteList() {
// 加载订阅站点列表 // 加载订阅站点列表
if (!siteList.value.length) { if (!siteList.value.length)
await loadSites(); await loadSites()
}
const maps = siteList.value.map((item) => { const maps = siteList.value.map((item) => {
return { return {
title: item.name, title: item.name,
value: item.id, value: item.id,
}; }
}); })
selectSitesOptions.value = maps.flat();
}; selectSitesOptions.value = maps.flat()
}
// 编辑订阅响应 // 编辑订阅响应
const editSubscribeDialog = async () => { async function editSubscribeDialog() {
await getSiteList(); await getSiteList()
subscribeInfoDialog.value = true; subscribeInfoDialog.value = true
}; }
// 弹出菜单 // 弹出菜单
const dropdownItems = ref([ const dropdownItems = ref([
{ {
title: "编辑", title: '编辑',
value: 1, value: 1,
props: { props: {
prependIcon: "mdi-file-edit-outline", prependIcon: 'mdi-file-edit-outline',
click: editSubscribeDialog, click: editSubscribeDialog,
}, },
}, },
{ {
title: "搜索", title: '搜索',
value: 2, value: 2,
props: { props: {
prependIcon: "mdi-magnify", prependIcon: 'mdi-magnify',
click: searchSubscribe, click: searchSubscribe,
}, },
}, },
{ {
title: "取消订阅", title: '取消订阅',
value: 3, value: 3,
props: { props: {
prependIcon: "mdi-trash-can-outline", prependIcon: 'mdi-trash-can-outline',
color: "error", color: 'error',
click: removeSubscribe, click: removeSubscribe,
}, },
}, },
]); ])
// 订阅编辑表单
const subscribeForm = reactive({
id: props.media?.id,
// 搜索关键字
keyword: props.media?.keyword,
// 过滤规则
filter: props.media?.filter,
// 包含
include: props.media?.include,
// 排除
exclude: props.media?.exclude,
// 总集数
total_episode: props.media?.total_episode,
// 开始集数
start_episode: props.media?.start_episode,
// 订阅站点
sites: props.media?.sites,
});
</script> </script>
<template> <template>
<VCard :key="props.media?.id" v-if="cardState" @click="editSubscribeDialog"> <VCard
v-if="cardState"
:key="props.media?.id"
@click="editSubscribeDialog"
>
<template #image> <template #image>
<VImg <VImg
:src="props.media?.backdrop || props.media?.poster" :src="props.media?.backdrop || props.media?.poster"
@@ -215,7 +231,11 @@ const subscribeForm = reactive({
</template> </template>
<VCardItem> <VCardItem>
<template #prepend> <template #prepend>
<VIcon size="1.9rem" :color="getTextColor()" :icon="getIcon()" /> <VIcon
size="1.9rem"
:color="getTextColor()"
:icon="getIcon()"
/>
</template> </template>
<VCardTitle :class="getTextClass()"> <VCardTitle :class="getTextClass()">
{{ props.media?.name }} {{ props.media?.name }}
@@ -224,20 +244,26 @@ const subscribeForm = reactive({
<template #append> <template #append>
<div class="me-n3"> <div class="me-n3">
<IconBtn> <IconBtn>
<VIcon icon="mdi-dots-vertical" :color="getTextColor()" /> <VIcon
<VMenu activator="parent" close-on-content-click> icon="mdi-dots-vertical"
:color="getTextColor()"
/>
<VMenu
activator="parent"
close-on-content-click
>
<VList> <VList>
<VListItem <VListItem
v-for="(item, i) in dropdownItems" v-for="(item, i) in dropdownItems"
:key="i"
variant="plain" variant="plain"
:base-color="item.props.color" :base-color="item.props.color"
:key="i"
@click="item.props.click" @click="item.props.click"
> >
<template #prepend> <template #prepend>
<VIcon :icon="item.props.prependIcon"></VIcon> <VIcon :icon="item.props.prependIcon" />
</template> </template>
<VListItemTitle v-text="item.title"></VListItemTitle> <VListItemTitle v-text="item.title" />
</VListItem> </VListItem>
</VList> </VList>
</VMenu> </VMenu>
@@ -247,51 +273,63 @@ const subscribeForm = reactive({
</VCardItem> </VCardItem>
<VCardText> <VCardText>
<p class="clamp-text mb-0" :class="getTextClass()"> <p
class="clamp-text mb-0"
:class="getTextClass()"
>
{{ props.media?.description }} {{ props.media?.description }}
</p> </p>
</VCardText> </VCardText>
<VCardText class="d-flex justify-space-between align-center flex-wrap"> <VCardText class="d-flex justify-space-between align-center flex-wrap">
<div class="d-flex align-center"> <div class="d-flex align-center">
<IconBtn icon="mdi-star" :color="getTextColor()" class="me-1" /> <IconBtn
<span class="text-subtitle-2 me-4" :class="getTextClass()">{{ icon="mdi-star"
:color="getTextColor()"
class="me-1"
/>
<span
class="text-subtitle-2 me-4"
:class="getTextClass()"
>{{
props.media?.vote props.media?.vote
}}</span> }}</span>
<IconBtn <IconBtn
v-if="props.media?.total_episode"
v-bind="props" v-bind="props"
icon="mdi-progress-clock" icon="mdi-progress-clock"
:color="getTextColor()" :color="getTextColor()"
class="me-1" class="me-1"
v-if="props.media?.total_episode"
/> />
<span <span
v-if="props.media?.season"
class="text-subtitle-2 me-4" class="text-subtitle-2 me-4"
:class="getTextClass()" :class="getTextClass()"
v-if="props.media?.season"
>{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} / >{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
{{ props.media?.total_episode }}</span {{ props.media?.total_episode }}</span>
>
<IconBtn <IconBtn
v-if="props.media?.username"
icon="mdi-account" icon="mdi-account"
:color="getTextColor()" :color="getTextColor()"
class="me-1" class="me-1"
v-if="props.media?.username"
/> />
<span <span
v-if="props.media?.username"
class="text-subtitle-2 me-4" class="text-subtitle-2 me-4"
:class="getTextClass()" :class="getTextClass()"
v-if="props.media?.username"
> >
{{ props.media?.username }} {{ props.media?.username }}
</span> </span>
</div> </div>
</VCardText> </VCardText>
<VCardText <VCardText
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300"
v-if="lastUpdateText" v-if="lastUpdateText"
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300"
> >
<VIcon icon="mdi-download" class="me-1" /> {{ lastUpdateText }} <VIcon
icon="mdi-download"
class="me-1"
/> {{ lastUpdateText }}
</VCardText> </VCardText>
<VProgressLinear <VProgressLinear
v-if="getPercentage() > 0" v-if="getPercentage() > 0"
@@ -301,23 +339,42 @@ const subscribeForm = reactive({
/> />
</VCard> </VCard>
<!-- 订阅编辑弹窗 --> <!-- 订阅编辑弹窗 -->
<VDialog v-model="subscribeInfoDialog" max-width="1000" persistent scrollable> <VDialog
v-model="subscribeInfoDialog"
max-width="1000"
persistent
scrollable
>
<!-- Dialog Content --> <!-- Dialog Content -->
<VCard :title="`编辑订阅 - ${props.media?.name}`"> <VCard :title="`编辑订阅 - ${props.media?.name}`">
<VCardText> <VCardText>
<VForm @submit.prevent="() => {}"> <VForm @submit.prevent="() => {}">
<VRow> <VRow>
<VCol cols="12" md="6"> <VCol
<VTextField v-model="subscribeForm.keyword" label="搜索关键词" /> cols="12"
md="6"
>
<VTextField
v-model="subscribeForm.keyword"
label="搜索关键词"
/>
</VCol> </VCol>
<VCol cols="12" md="3" v-if="props.media?.type == '电视剧'"> <VCol
v-if="props.media?.type === '电视剧'"
cols="12"
md="3"
>
<VTextField <VTextField
v-model="subscribeForm.total_episode" v-model="subscribeForm.total_episode"
label="总集数" label="总集数"
:rules="[numberValidator]" :rules="[numberValidator]"
/> />
</VCol> </VCol>
<VCol cols="12" md="3" v-if="props.media?.type == '电视剧'"> <VCol
v-if="props.media?.type === '电视剧'"
cols="12"
md="3"
>
<VTextField <VTextField
v-model="subscribeForm.start_episode" v-model="subscribeForm.start_episode"
label="开始集数" label="开始集数"
@@ -326,13 +383,19 @@ const subscribeForm = reactive({
</VCol> </VCol>
</VRow> </VRow>
<VRow> <VRow>
<VCol cols="12" md="6"> <VCol
cols="12"
md="6"
>
<VTextField <VTextField
v-model="subscribeForm.include" v-model="subscribeForm.include"
label="包含(关键字、正则式)" label="包含(关键字、正则式)"
/> />
</VCol> </VCol>
<VCol cols="12" md="6"> <VCol
cols="12"
md="6"
>
<VTextField <VTextField
v-model="subscribeForm.exclude" v-model="subscribeForm.exclude"
label="排除(关键字、正则式)" label="排除(关键字、正则式)"
@@ -347,16 +410,20 @@ const subscribeForm = reactive({
chips chips
label="订阅站点" label="订阅站点"
multiple multiple
></VSelect> />
</VCol> </VCol>
</VRow> </VRow>
</VForm> </VForm>
</VCardText> </VCardText>
<VCardActions> <VCardActions>
<VBtn @click="subscribeInfoDialog = false"> 取消 </VBtn> <VBtn @click="subscribeInfoDialog = false">
取消
</VBtn>
<VSpacer /> <VSpacer />
<VBtn @click="updateSubscribeInfo"> 确定 </VBtn> <VBtn @click="updateSubscribeInfo">
确定
</VBtn>
</VCardActions> </VCardActions>
</VCard> </VCard>
</VDialog> </VDialog>
+121 -92
View File
@@ -1,10 +1,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import { formatFileSize } from "@/@core/utils/formatters"; import type { PropType } from 'vue'
import api from "@/api"; import { useToast } from 'vue-toast-notification'
import { doneNProgress, startNProgress } from "@/api/nprogress"; import { useConfirm } from 'vuetify-use-dialog'
import { Context } from "@/api/types"; import { formatFileSize } from '@/@core/utils/formatters'
import { useToast } from "vue-toast-notification"; import api from '@/api'
import { useConfirm } from "vuetify-use-dialog"; import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { Context } from '@/api/types'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
@@ -12,135 +13,152 @@ const props = defineProps({
more: Array as PropType<Context[]>, more: Array as PropType<Context[]>,
width: String, width: String,
height: String, height: String,
}); })
// 提示框 // 提示框
const $toast = useToast(); const $toast = useToast()
// 确认框 // 确认框
const createConfirm = useConfirm(); const createConfirm = useConfirm()
// 更多来源界面 // 更多来源界面
const showMoreTorrents = ref(false); const showMoreTorrents = ref(false)
// 种子信息 // 种子信息
const torrent = ref(props.torrent?.torrent_info); const torrent = ref(props.torrent?.torrent_info)
// 媒体信息 // 媒体信息
const media = ref(props.torrent?.media_info); const media = ref(props.torrent?.media_info)
// 识别元数据 // 识别元数据
const meta = ref(props.torrent?.meta_info); const meta = ref(props.torrent?.meta_info)
// 站点图标 // 站点图标
const siteIcon = ref(""); const siteIcon = ref('')
// 查询站点图标 // 查询站点图标
const getSiteIcon = async () => { async function getSiteIcon() {
try { try {
siteIcon.value = (await api.get("site/icon/" + torrent?.value?.site)).data.icon; siteIcon.value = (await api.get(`site/icon/${torrent?.value?.site}`)).data.icon
} catch (error) { }
console.error(error); catch (error) {
console.error(error)
}
} }
};
// 询问并添加下载 // 询问并添加下载
const handleAddDownload = async ( async function handleAddDownload(_site: any = undefined,
_site: any = undefined,
_media: any = undefined, _media: any = undefined,
_torrent: any = undefined _torrent: any = undefined) {
) => {
if (!_media || !_torrent || !_site) { if (!_media || !_torrent || !_site) {
_site = torrent.value?.site_name; _site = torrent.value?.site_name
_media = media.value; _media = media.value
_torrent = torrent.value; _torrent = torrent.value
} }
const isConfirmed = await createConfirm({ const isConfirmed = await createConfirm({
title: "确认", title: '确认',
content: `是否确认下载【${_site}${_torrent?.title} ?`, content: `是否确认下载【${_site}${_torrent?.title} ?`,
confirmationText: "确认", confirmationText: '确认',
cancellationText: "取消", cancellationText: '取消',
dialogProps: { dialogProps: {
maxWidth: 600, maxWidth: 600,
}, },
}); })
if (!isConfirmed) return; if (!isConfirmed)
return
addDownload(_media, _torrent); addDownload(_media, _torrent)
}; }
// 添加下载 // 添加下载
const addDownload = async (_media: any, _torrent: any) => { async function addDownload(_media: any, _torrent: any) {
startNProgress(); startNProgress()
try { try {
const result: { [key: string]: any } = await api.post("download", { const result: { [key: string]: any } = await api.post('download', {
media_in: _media, media_in: _media,
torrent_in: _torrent, torrent_in: _torrent,
}); })
if (result.success) { if (result.success) {
// 添加下载成功 // 添加下载成功
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`); $toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`)
} else { }
else {
// 添加下载失败 // 添加下载失败
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败!`); $toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败!`)
} }
} catch (error) {
console.error(error);
} }
doneNProgress(); catch (error) {
}; console.error(error)
}
doneNProgress()
}
// 打开种子详情页面 // 打开种子详情页面
const openTorrentDetail = () => { function openTorrentDetail() {
window.open(torrent.value?.page_url, "_blank"); window.open(torrent.value?.page_url, '_blank')
}; }
// 下载种子文件 // 下载种子文件
const downloadTorrentFile = async () => { async function downloadTorrentFile() {
window.open(torrent.value?.enclosure, "_blank"); window.open(torrent.value?.enclosure, '_blank')
}; }
// 促销Chip类 // 促销Chip类
const getVolumeFactorClass = (downloadVolume: number, uploadVolume: number) => { function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
if (downloadVolume === 0) { if (downloadVolume === 0)
return "text-white bg-lime-500"; return 'text-white bg-lime-500'
} else if (downloadVolume < 1) { else if (downloadVolume < 1)
return "text-white bg-green-500"; return 'text-white bg-green-500'
} else if (uploadVolume != 1) { else if (uploadVolume !== 1)
return "text-white bg-sky-500"; return 'text-white bg-sky-500'
} else { else
return "text-white bg-gray-500"; return 'text-white bg-gray-500'
} }
};
// 装载时查询站点图标 // 装载时查询站点图标
onMounted(() => { onMounted(() => {
getSiteIcon(); getSiteIcon()
}); })
// 弹出菜单 // 弹出菜单
const dropdownItems = ref([ const dropdownItems = ref([
{ {
title: "查看详情", title: '查看详情',
value: 1, value: 1,
props: { props: {
prependIcon: "mdi-information", prependIcon: 'mdi-information',
click: openTorrentDetail, click: openTorrentDetail,
}, },
}, },
{ {
title: "下载种子", title: '下载种子',
value: 2, value: 2,
props: { props: {
prependIcon: "mdi-download", prependIcon: 'mdi-download',
click: downloadTorrentFile, click: downloadTorrentFile,
}, },
}, },
]); ])
</script> </script>
<template> <template>
<VCard :width="props.width" :height="props.height" @click="handleAddDownload"> <VCard
<template #image v-if="!showMoreTorrents"> :width="props.width"
<VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0"> :height="props.height"
@click="handleAddDownload"
>
<template
v-if="!showMoreTorrents"
#image
>
<VAvatar
class="absolute right-2 bottom-2 rounded"
variant="flat"
rounded="0"
>
<VImg :src="siteIcon" /> <VImg :src="siteIcon" />
</VAvatar> </VAvatar>
</template> </template>
@@ -153,19 +171,25 @@ const dropdownItems = ref([
<template #append> <template #append>
<div class="me-n3"> <div class="me-n3">
<IconBtn> <IconBtn>
<VIcon icon="mdi-dots-vertical" color="white" /> <VIcon
<VMenu activator="parent" close-on-content-click> icon="mdi-dots-vertical"
color="white"
/>
<VMenu
activator="parent"
close-on-content-click
>
<VList> <VList>
<VListItem <VListItem
v-for="(item, i) in dropdownItems" v-for="(item, i) in dropdownItems"
variant="plain"
:key="i" :key="i"
variant="plain"
@click="item.props.click" @click="item.props.click"
> >
<template #prepend> <template #prepend>
<VIcon :icon="item.props.prependIcon"></VIcon> <VIcon :icon="item.props.prependIcon" />
</template> </template>
<VListItemTitle v-text="item.title"></VListItemTitle> <VListItemTitle v-text="item.title" />
</VListItem> </VListItem>
</VList> </VList>
</VMenu> </VMenu>
@@ -177,23 +201,28 @@ const dropdownItems = ref([
{{ torrent?.title }} {{ torrent?.title }}
</VCardText> </VCardText>
<VCardText>{{ torrent?.description }}</VCardText> <VCardText>{{ torrent?.description }}</VCardText>
<VCardItem class="pb-3 pt-0 pe-12" v-if="torrent?.labels"> <VCardItem
v-if="torrent?.labels"
class="pb-3 pt-0 pe-12"
>
<VChip <VChip
v-for="(label, index) in torrent?.labels"
:key="index"
variant="elevated" variant="elevated"
size="small" size="small"
v-for="label in torrent?.labels"
color="primary" color="primary"
class="me-1 mb-1" class="me-1 mb-1"
>{{ label }}</VChip
> >
{{ label }}
</VChip>
<VChip <VChip
v-if="meta?.edition" v-if="meta?.edition"
variant="elevated" variant="elevated"
size="small" size="small"
class="me-1 mb-1 text-white bg-red-500" class="me-1 mb-1 text-white bg-red-500"
> >
{{ meta?.edition }}</VChip {{ meta?.edition }}
> </VChip>
<VChip <VChip
v-if="meta?.resource_pix" v-if="meta?.resource_pix"
variant="elevated" variant="elevated"
@@ -240,26 +269,27 @@ const dropdownItems = ref([
</VCardItem> </VCardItem>
<VCardActions> <VCardActions>
<VBtn <VBtn
@click.stop="showMoreTorrents = !showMoreTorrents"
v-if="props.more && props.more.length > 0" v-if="props.more && props.more.length > 0"
@click.stop="showMoreTorrents = !showMoreTorrents"
> >
<template #append> <template #append>
<VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'"></VIcon> <VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'" />
</template> </template>
更多来源 更多来源
</VBtn> </VBtn>
</VCardActions> </VCardActions>
<VExpandTransition> <VExpandTransition>
<div v-show="showMoreTorrents"> <div v-show="showMoreTorrents">
<VDivider></VDivider> <VDivider />
<VChipGroup class="p-3"> <VChipGroup class="p-3">
<VChip <VChip
v-for="item in props.more" v-for="(item, index) in props.more"
:key="index"
@click.stop=" @click.stop="
handleAddDownload( handleAddDownload(
item.torrent_info?.site_name, item.torrent_info?.site_name,
item.media_info, item.media_info,
item.torrent_info item.torrent_info,
) )
" "
> >
@@ -269,17 +299,16 @@ const dropdownItems = ref([
:content="`↑${item.torrent_info?.seeders}`" :content="`↑${item.torrent_info?.seeders}`"
inline inline
size="small" size="small"
></VBadge> />
<VBadge <VBadge
v-if="
item.torrent_info?.downloadvolumefactor !== 1
|| item.torrent_info?.uploadvolumefactor !== 1
"
:content="item.torrent_info?.volume_factor" :content="item.torrent_info?.volume_factor"
inline inline
size="small" size="small"
v-if=" />
item.torrent_info?.downloadvolumefactor !== 1 ||
item.torrent_info?.uploadvolumefactor !== 1
"
>
</VBadge>
</template> </template>
{{ item.torrent_info.site_name }} {{ item.torrent_info.site_name }}
</VChip> </VChip>
@@ -1,14 +1,14 @@
<script lang="ts" setup> <script lang="ts" setup>
import VerticalNavSectionTitle from "@/@layouts/components/VerticalNavSectionTitle.vue"; import VerticalNavSectionTitle from '@/@layouts/components/VerticalNavSectionTitle.vue'
import VerticalNavLayout from "@layouts/components/VerticalNavLayout.vue"; import VerticalNavLayout from '@layouts/components/VerticalNavLayout.vue'
import VerticalNavLink from "@layouts/components/VerticalNavLink.vue"; import VerticalNavLink from '@layouts/components/VerticalNavLink.vue'
// Components // Components
import Footer from "@/layouts/components/Footer.vue"; import Footer from '@/layouts/components/Footer.vue'
import NavbarThemeSwitcher from "@/layouts/components/NavbarThemeSwitcher.vue"; import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue'
import SearchBar from "@/layouts/components/SearchBar.vue"; import SearchBar from '@/layouts/components/SearchBar.vue'
import ShortcutBar from "@/layouts/components/ShortcutBar.vue"; import ShortcutBar from '@/layouts/components/ShortcutBar.vue'
import UserProfile from "@/layouts/components/UserProfile.vue"; import UserProfile from '@/layouts/components/UserProfile.vue'
</script> </script>
<template> <template>
@@ -17,7 +17,10 @@ import UserProfile from "@/layouts/components/UserProfile.vue";
<template #navbar="{ toggleVerticalOverlayNavActive }"> <template #navbar="{ toggleVerticalOverlayNavActive }">
<div class="d-flex h-100 align-center mx-1"> <div class="d-flex h-100 align-center mx-1">
<!-- 👉 Vertical Nav Toggle --> <!-- 👉 Vertical Nav Toggle -->
<IconBtn class="ms-n2 d-lg-none" @click="toggleVerticalOverlayNavActive(true)"> <IconBtn
class="ms-n2 d-lg-none"
@click="toggleVerticalOverlayNavActive(true)"
>
<VIcon icon="mdi-menu" /> <VIcon icon="mdi-menu" />
</IconBtn> </IconBtn>
@@ -149,7 +152,7 @@ import UserProfile from "@/layouts/components/UserProfile.vue";
:item="{ :item="{
title: '设定', title: '设定',
icon: 'mdi-cog', icon: 'mdi-cog',
to: '/account-setting', to: '/setting',
}" }"
/> />
</template> </template>
+36 -18
View File
@@ -1,35 +1,45 @@
<script lang="ts" setup> <script lang="ts" setup>
// 路由 // 路由
const router = useRouter(); const router = useRouter()
// 搜索词 // 搜索词
const searchWord = ref<string>(""); const searchWord = ref<string>('')
// 搜索弹窗 // 搜索弹窗
const searchDialog = ref(false); const searchDialog = ref(false)
// Search // Search
const search = () => { function search() {
if (!searchWord.value) { if (!searchWord.value)
return; return
}
searchDialog.value = false; searchDialog.value = false
router.push({ router.push({
path: "/browse/media/search", path: '/browse/media/search',
query: { query: {
title: searchWord.value, title: searchWord.value,
}, },
}); })
}; }
</script> </script>
<template> <template>
<!-- 👉 Search Button --> <!-- 👉 Search Button -->
<div class="d-flex align-center cursor-pointer" style="user-select: none"> <div
<VDialog v-model="searchDialog" max-width="600" transition="dialog-top-transition"> class="d-flex align-center cursor-pointer"
style="user-select: none"
>
<VDialog
v-model="searchDialog"
max-width="600"
transition="dialog-top-transition"
>
<!-- Dialog Activator --> <!-- Dialog Activator -->
<template #activator="{ props }"> <template #activator="{ props }">
<IconBtn class="d-lg-none" v-bind="props"> <IconBtn
class="d-lg-none"
v-bind="props"
>
<VIcon icon="mdi-magnify" /> <VIcon icon="mdi-magnify" />
</IconBtn> </IconBtn>
</template> </template>
@@ -38,14 +48,22 @@ const search = () => {
<VCardText> <VCardText>
<VRow> <VRow>
<VCol cols="12"> <VCol cols="12">
<VTextField v-model="searchWord" label="电影、电视剧名称" /> <VTextField
v-model="searchWord"
label="电影、电视剧名称"
/>
</VCol> </VCol>
</VRow> </VRow>
</VCardText> </VCardText>
<VCardActions> <VCardActions>
<VSpacer /> <VSpacer />
<VBtn @click="search" @keydown.enter="search"> 搜索 </VBtn> <VBtn
@click="search"
@keydown.enter="search"
>
搜索
</VBtn>
</VCardActions> </VCardActions>
</VCard> </VCard>
</VDialog> </VDialog>
@@ -63,10 +81,10 @@ const search = () => {
append-inner-icon="mdi-magnify" append-inner-icon="mdi-magnify"
single-line single-line
hide-details hide-details
@click:append-inner="search"
@keydown.enter="search"
flat flat
rounded rounded
@click:append-inner="search"
@keydown.enter="search"
/> />
</span> </span>
</template> </template>
+44 -16
View File
@@ -1,15 +1,15 @@
<script lang="ts" setup> <script lang="ts" setup>
import NameTestView from "@/views/system/NameTestView.vue"; import NameTestView from '@/views/system/NameTestView.vue'
import NetTestView from "@/views/system/NetTestView.vue"; import NetTestView from '@/views/system/NetTestView.vue'
// App捷径 // App捷径
const appsMenu = ref(false); const appsMenu = ref(false)
// 名称测试弹窗 // 名称测试弹窗
const nameTestDialog = ref(false); const nameTestDialog = ref(false)
// 网络测试弹窗 // 网络测试弹窗
const netTestDialog = ref(false); const netTestDialog = ref(false)
</script> </script>
<template> <template>
@@ -25,7 +25,10 @@ const netTestDialog = ref(false);
> >
<!-- Menu Activator --> <!-- Menu Activator -->
<template #activator="{ props }"> <template #activator="{ props }">
<IconBtn class="me-2" v-bind="props"> <IconBtn
class="me-2"
v-bind="props"
>
<VIcon icon="mdi-checkbox-multiple-blank-outline" /> <VIcon icon="mdi-checkbox-multiple-blank-outline" />
</IconBtn> </IconBtn>
</template> </template>
@@ -41,25 +44,44 @@ const netTestDialog = ref(false);
</VCardItem> </VCardItem>
<div class="ps ps--active-y"> <div class="ps ps--active-y">
<VRow class="ma-0 mt-n1"> <VRow class="ma-0 mt-n1">
<VCol cols="6" class="text-center cursor-pointer pa-0 shortcut-icon border-e"> <VCol
<VListItem @click="nameTestDialog = true" class="pa-4"> cols="6"
<VAvatar size="48" variant="tonal"> class="text-center cursor-pointer pa-0 shortcut-icon border-e"
>
<VListItem
class="pa-4"
@click="nameTestDialog = true"
>
<VAvatar
size="48"
variant="tonal"
>
<VIcon icon="mdi-text-recognition" /> <VIcon icon="mdi-text-recognition" />
</VAvatar> </VAvatar>
<h6 class="text-base font-weight-medium mt-2 mb-0">识别</h6> <h6 class="text-base font-weight-medium mt-2 mb-0">
识别
</h6>
<span class="text-sm">名称识别测试</span> <span class="text-sm">名称识别测试</span>
</VListItem> </VListItem>
</VCol> </VCol>
<VCol <VCol
@click="() => {}"
cols="6" cols="6"
class="text-center cursor-pointer pa-0 shortcut-icon" class="text-center cursor-pointer pa-0 shortcut-icon"
@click="() => {}"
>
<VListItem
class="pa-4"
@click="netTestDialog = true"
>
<VAvatar
size="48"
variant="tonal"
> >
<VListItem @click="netTestDialog = true" class="pa-4">
<VAvatar size="48" variant="tonal">
<VIcon icon="mdi-network-outline" /> <VIcon icon="mdi-network-outline" />
</VAvatar> </VAvatar>
<h6 class="text-base font-weight-medium mt-2 mb-0">网络</h6> <h6 class="text-base font-weight-medium mt-2 mb-0">
网络
</h6>
<span class="text-sm">测试网速连通性</span> <span class="text-sm">测试网速连通性</span>
</VListItem> </VListItem>
</VCol> </VCol>
@@ -68,7 +90,10 @@ const netTestDialog = ref(false);
</VCard> </VCard>
</VMenu> </VMenu>
<!-- 名称测试弹窗 --> <!-- 名称测试弹窗 -->
<VDialog v-model="nameTestDialog" max-width="800"> <VDialog
v-model="nameTestDialog"
max-width="800"
>
<VCard title="名称识别测试"> <VCard title="名称识别测试">
<DialogCloseBtn @click="nameTestDialog = false" /> <DialogCloseBtn @click="nameTestDialog = false" />
<VCardItem> <VCardItem>
@@ -77,7 +102,10 @@ const netTestDialog = ref(false);
</VCard> </VCard>
</VDialog> </VDialog>
<!-- 网络测试弹窗 --> <!-- 网络测试弹窗 -->
<VDialog v-model="netTestDialog" max-width="600"> <VDialog
v-model="netTestDialog"
max-width="600"
>
<VCard title="网络测试"> <VCard title="网络测试">
<DialogCloseBtn @click="netTestDialog = false" /> <DialogCloseBtn @click="netTestDialog = false" />
<VCardItem> <VCardItem>
+50 -16
View File
@@ -1,30 +1,46 @@
<script setup lang="ts"> <script setup lang="ts">
import router from "@/router"; import { useStore } from 'vuex'
import { useStore } from "vuex"; import router from '@/router'
// Vuex Store // Vuex Store
const store = useStore(); const store = useStore()
// 执行注销操作 // 执行注销操作
const logout = () => { function logout() {
// 清除登录状态信息 // 清除登录状态信息
store.dispatch("auth/clearToken"); store.dispatch('auth/clearToken')
// 重定向到登录页面或其他适当的页面 // 重定向到登录页面或其他适当的页面
router.push("/login"); router.push('/login')
}; }
// 获取当前用户信息 // 获取当前用户信息
const accountInfo: any = inject("accountInfo"); const accountInfo: any = inject('accountInfo')
</script> </script>
<template> <template>
<VBadge dot location="bottom right" offset-x="3" offset-y="3" color="success" bordered> <VBadge
<VAvatar class="cursor-pointer" color="primary" variant="tonal"> dot
location="bottom right"
offset-x="3"
offset-y="3"
color="success"
bordered
>
<VAvatar
class="cursor-pointer"
color="primary"
variant="tonal"
>
<VImg :src="accountInfo.avatar" /> <VImg :src="accountInfo.avatar" />
<!-- SECTION Menu --> <!-- SECTION Menu -->
<VMenu activator="parent" width="230" location="bottom end" offset="14px"> <VMenu
activator="parent"
width="230"
location="bottom end"
offset="14px"
>
<VList> <VList>
<!-- 👉 User Avatar & Name --> <!-- 👉 User Avatar & Name -->
<VListItem> <VListItem>
@@ -37,7 +53,10 @@ const accountInfo: any = inject("accountInfo");
offset-y="3" offset-y="3"
color="success" color="success"
> >
<VAvatar color="primary" variant="tonal"> <VAvatar
color="primary"
variant="tonal"
>
<VImg :src="accountInfo.avatar" /> <VImg :src="accountInfo.avatar" />
</VAvatar> </VAvatar>
</VBadge> </VBadge>
@@ -52,9 +71,16 @@ const accountInfo: any = inject("accountInfo");
<VDivider class="my-2" /> <VDivider class="my-2" />
<!-- 👉 Profile --> <!-- 👉 Profile -->
<VListItem link to="account-setting"> <VListItem
link
to="account-setting"
>
<template #prepend> <template #prepend>
<VIcon class="me-2" icon="mdi-account-outline" size="22" /> <VIcon
class="me-2"
icon="mdi-account-outline"
size="22"
/>
</template> </template>
<VListItemTitle>设定</VListItemTitle> <VListItemTitle>设定</VListItemTitle>
@@ -66,7 +92,11 @@ const accountInfo: any = inject("accountInfo");
target="_blank" target="_blank"
> >
<template #prepend> <template #prepend>
<VIcon class="me-2" icon="mdi-help-circle-outline" size="22" /> <VIcon
class="me-2"
icon="mdi-help-circle-outline"
size="22"
/>
</template> </template>
<VListItemTitle>帮助</VListItemTitle> <VListItemTitle>帮助</VListItemTitle>
@@ -78,7 +108,11 @@ const accountInfo: any = inject("accountInfo");
<!-- 👉 Logout --> <!-- 👉 Logout -->
<VListItem @click="logout"> <VListItem @click="logout">
<template #prepend> <template #prepend>
<VIcon class="me-2" icon="mdi-logout" size="22" /> <VIcon
class="me-2"
icon="mdi-logout"
size="22"
/>
</template> </template>
<VListItemTitle>注销</VListItemTitle> <VListItemTitle>注销</VListItemTitle>
+6 -13
View File
@@ -1,7 +1,9 @@
/* eslint-disable import/order */ import { createApp } from 'vue'
import '@/@iconify/icons-bundle' import '@/@iconify/icons-bundle'
import App from '@/App.vue' import ToastPlugin from 'vue-toast-notification'
import VuetifyUseDialog from 'vuetify-use-dialog'
import { configureNProgress, doneNProgress, startNProgress } from '@/api/nprogress' import { configureNProgress, doneNProgress, startNProgress } from '@/api/nprogress'
import App from '@/App.vue'
import vuetify from '@/plugins/vuetify' import vuetify from '@/plugins/vuetify'
import { loadFonts } from '@/plugins/webfontloader' import { loadFonts } from '@/plugins/webfontloader'
import router from '@/router' import router from '@/router'
@@ -9,10 +11,8 @@ import store from '@/store'
import '@core/scss/template/index.scss' import '@core/scss/template/index.scss'
import '@layouts/styles/index.scss' import '@layouts/styles/index.scss'
import '@styles/styles.scss' import '@styles/styles.scss'
import { createApp } from 'vue'
import ToastPlugin from 'vue-toast-notification'
import 'vue-toast-notification/dist/theme-default.css' import 'vue-toast-notification/dist/theme-default.css'
import VuetifyUseDialog from 'vuetify-use-dialog'
loadFonts() loadFonts()
// Nprogress // Nprogress
@@ -22,14 +22,7 @@ configureNProgress()
const app = createApp(App) const app = createApp(App)
// Use plugins Mount vue app // Use plugins Mount vue app
app app.use(vuetify).use(router).use(store).use(ToastPlugin).use(VuetifyUseDialog).mount('#app')
.use(vuetify)
.use(router)
.use(store)
.use(ToastPlugin)
.use(VuetifyUseDialog)
.mount('#app')
// 导航守卫 // 导航守卫
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
+4 -4
View File
@@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import NoDataFound from '@/components/NoDataFound.vue'; import NoDataFound from '@/components/NoDataFound.vue'
</script> </script>
<template> <template>
<NoDataFound <NoDataFound
errorCode="404" error-code="404"
errorTitle="页面不存在 ⚠️" error-title="页面不存在 "
errorDescription="您想要访问的页面不存在请检查地址是否正确。" error-description="您想要访问的页面不存在请检查地址是否正确"
> >
<template #button> <template #button>
<VBtn <VBtn
-59
View File
@@ -1,59 +0,0 @@
<script lang="ts" setup>
import AccountSettingAccount from "@/views/account-setting/AccountSettingAccount.vue";
import AccountSettingNotification from "@/views/account-setting/AccountSettingNotification.vue";
import AccountSettingRule from "@/views/account-setting/AccountSettingRule.vue";
import AccountSettingSite from "@/views/account-setting/AccountSettingSite.vue";
import AccountSettingWords from "@/views/account-setting/AccountSettingWords.vue";
import { useRoute } from "vue-router";
const route = useRoute();
const activeTab = ref(route.params.tab);
// tabs
const tabs = [
{ title: "用户", icon: "mdi-account", tab: "account" },
{ title: "站点", icon: "mdi-web", tab: "site" },
{ title: "规则", icon: "mdi-filter-cog", tab: "filter" },
{ title: "通知", icon: "mdi-bell", tab: "notification" },
{ title: "自定义词表", icon: "mdi-file-word-box", tab: "words" },
];
</script>
<template>
<div>
<VTabs v-model="activeTab" show-arrows>
<VTab v-for="item in tabs" :key="item.icon" :value="item.tab">
<VIcon size="20" start :icon="item.icon" />
{{ item.title }}
</VTab>
</VTabs>
<VDivider />
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition">
<!-- Account -->
<VWindowItem value="account">
<AccountSettingAccount />
</VWindowItem>
<!-- System -->
<VWindowItem value="site">
<AccountSettingSite />
</VWindowItem>
<!-- Notification -->
<VWindowItem value="filter">
<AccountSettingRule />
</VWindowItem>
<!-- Notification -->
<VWindowItem value="notification">
<AccountSettingNotification />
</VWindowItem>
<!-- Words -->
<VWindowItem value="words">
<AccountSettingWords />
</VWindowItem>
</VWindow>
</div>
</template>
+31 -28
View File
@@ -1,58 +1,61 @@
<script setup lang="ts"> <script setup lang="ts">
import MediaCardListView from "@/views/discover/MediaCardListView.vue"; import MediaCardListView from '@/views/discover/MediaCardListView.vue'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
type: Array as PropType<string[]> | PropType<string>, type: Array as PropType<string[]> | PropType<string>,
}); })
// 路由参数 // 路由参数
const route = useRoute(); const route = useRoute()
// 面包屑标题定义 // 面包屑标题定义
const titles: { [key: string]: any } = { const titles: { [key: string]: any } = {
tmdb: { tmdb: {
trending: "流行趋势", trending: '流行趋势',
movies: "热门电影", movies: '热门电影',
tvs: "热门电视剧", tvs: '热门电视剧',
}, },
douban: { douban: {
movies: "最新电影", movies: '最新电影',
tvs: "最新电视剧", tvs: '最新电视剧',
tv_weekly_chinese: "华语剧集榜", tv_weekly_chinese: '华语剧集榜',
tv_weekly_global: "全球剧集榜", tv_weekly_global: '全球剧集榜',
movie_top250: "电影TOP250", movie_top250: '电影TOP250',
}, },
media: { media: {
search: "搜索", search: '搜索',
}, },
}; }
// 计算API路径 // 计算API路径
const getApiPath = (types: string[] | string) => { function getApiPath(types: string[] | string) {
if (Array.isArray(types)) { if (Array.isArray(types))
return types.join("/"); return types.join('/')
} else { else
return types; return types
} }
};
// 面包屑标题 // 面包屑标题
const getTitle = (types: string[] | string, title: any = "") => { function getTitle(types: string[] | string, title: any = '') {
if (Array.isArray(types)) { if (Array.isArray(types)) {
if (title) { if (title)
return [titles[types[0]][types[1]], title]; return [titles[types[0]][types[1]], title]
return ['推荐', titles[types[0]][types[1]]]
}
else {
return ['发现']
} }
return ["推荐", titles[types[0]][types[1]]];
} else {
return ["发现"];
} }
};
</script> </script>
<template> <template>
<div> <div>
<VBreadcrumbs :items="getTitle(props.type || '', route.query?.title)"></VBreadcrumbs> <VBreadcrumbs :items="getTitle(props.type || '', route.query?.title)" />
<MediaCardListView :apipath="getApiPath(props.type || '')" :params="route.query" /> <MediaCardListView
:apipath="getApiPath(props.type || '')"
:params="route.query"
/>
</div> </div>
</template> </template>
+1 -2
View File
@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import FullCalendarView from '@/views/subscribe/FullCalendarView.vue'; import FullCalendarView from '@/views/subscribe/FullCalendarView.vue'
</script> </script>
<template> <template>
+26 -11
View File
@@ -1,31 +1,46 @@
<script setup lang="ts"> <script setup lang="ts">
import AnalyticsMediaStatistic from "@/views/dashboard/AnalyticsMediaStatistic.vue"; import AnalyticsMediaStatistic from '@/views/dashboard/AnalyticsMediaStatistic.vue'
import AnalyticsProcesses from "@/views/dashboard/AnalyticsProcesses.vue"; import AnalyticsProcesses from '@/views/dashboard/AnalyticsProcesses.vue'
import AnalyticsScheduler from "@/views/dashboard/AnalyticsScheduler.vue"; import AnalyticsScheduler from '@/views/dashboard/AnalyticsScheduler.vue'
import AnalyticsSpeed from "@/views/dashboard/AnalyticsSpeed.vue"; import AnalyticsSpeed from '@/views/dashboard/AnalyticsSpeed.vue'
import AnalyticsStorage from "@/views/dashboard/AnalyticsStorage.vue"; import AnalyticsStorage from '@/views/dashboard/AnalyticsStorage.vue'
import AnalyticsWeeklyOverview from "@/views/dashboard/AnalyticsWeeklyOverview.vue"; import AnalyticsWeeklyOverview from '@/views/dashboard/AnalyticsWeeklyOverview.vue'
</script> </script>
<template> <template>
<VRow class="match-height"> <VRow class="match-height">
<VCol cols="12" md="4"> <VCol
cols="12"
md="4"
>
<AnalyticsStorage /> <AnalyticsStorage />
</VCol> </VCol>
<VCol cols="12" md="8"> <VCol
cols="12"
md="8"
>
<AnalyticsMediaStatistic /> <AnalyticsMediaStatistic />
</VCol> </VCol>
<VCol cols="12" md="4"> <VCol
cols="12"
md="4"
>
<AnalyticsWeeklyOverview /> <AnalyticsWeeklyOverview />
</VCol> </VCol>
<VCol cols="12" md="4"> <VCol
cols="12"
md="4"
>
<AnalyticsSpeed /> <AnalyticsSpeed />
</VCol> </VCol>
<VCol cols="12" md="4"> <VCol
cols="12"
md="4"
>
<AnalyticsScheduler /> <AnalyticsScheduler />
</VCol> </VCol>
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import DownloadingListView from "@/views/reorganize/DownloadingListView.vue"; import DownloadingListView from '@/views/reorganize/DownloadingListView.vue'
</script> </script>
<template> <template>
+1 -2
View File
@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import TransferHistoryView from '@/views/reorganize/TransferHistoryView.vue'; import TransferHistoryView from '@/views/reorganize/TransferHistoryView.vue'
</script> </script>
<template> <template>
+78 -60
View File
@@ -1,105 +1,109 @@
<script setup lang="ts"> <script setup lang="ts">
import { requiredValidator } from "@/@validators"; import { VForm } from 'vuetify/components/VForm'
import api from "@/api"; import { useStore } from 'vuex'
import router from "@/router"; import { requiredValidator } from '@/@validators'
import logo from "@images/logo.svg?raw"; import api from '@/api'
import type { VForm } from "vuetify/components/VForm"; import router from '@/router'
import { useStore } from "vuex"; import logo from '@images/logo.svg?raw'
// Vuex Store // Vuex Store
const store = useStore(); const store = useStore()
// 表单 // 表单
const form = ref({ const form = ref({
username: "", username: '',
password: "", password: '',
remember: true, remember: true,
}); })
const refForm = ref<InstanceType<typeof VForm> | null>(null);
const refForm = ref<InstanceType<typeof VForm> | null>(null)
// 密码输入 // 密码输入
const isPasswordVisible = ref(false); const isPasswordVisible = ref(false)
// 错误信息 // 错误信息
const errorMessage = ref(""); const errorMessage = ref('')
// 背景图片 // 背景图片
const backgroundImageUrl = ref(""); const backgroundImageUrl = ref('')
// 背景图片加载状态 // 背景图片加载状态
const isImageLoaded = ref(false); const isImageLoaded = ref(false)
// 获取背景图片 // 获取背景图片
const fetchBackgroundImage = async () => { async function fetchBackgroundImage() {
api api
.get("/login/tmdb") .get('/login/tmdb')
.then((response: any) => { .then((response: any) => {
backgroundImageUrl.value = response.message; backgroundImageUrl.value = response.message
}) })
.catch((error: any) => { .catch((error: any) => {
console.log(error); console.log(error)
}); })
};
// 登录获取token事件
const login = () => {
errorMessage.value = "";
// 进行表单校验
if (!form.value.username || !form.value.password) {
return;
} }
// 用户名密码 // 登录获取token事件
const formData = new FormData(); function login() {
errorMessage.value = ''
formData.append("username", form.value.username); // 进行表单校验
formData.append("password", form.value.password); if (!form.value.username || !form.value.password)
return
// 用户名密码
const formData = new FormData()
formData.append('username', form.value.username)
formData.append('password', form.value.password)
// 请求token // 请求token
api api
.post("/login/access-token", formData, { .post('/login/access-token', formData, {
headers: { headers: {
Accept: "application/json", // 设置 Accept 类型 Accept: 'application/json', // 设置 Accept 类型
}, },
}) })
.then((response: any) => { .then((response: any) => {
// 获取token // 获取token
const token = response.access_token; const token = response.access_token
// 更新token和remember状态到Vuex Store // 更新token和remember状态到Vuex Store
store.dispatch("auth/updateToken", token); store.dispatch('auth/updateToken', token)
store.dispatch("auth/updateRemember", form.value.remember); store.dispatch('auth/updateRemember', form.value.remember)
// 跳转到首页 // 跳转到首页
router.push("/"); router.push('/')
}) })
.catch((error: any) => { .catch((error: any) => {
// 登录失败,显示错误提示 // 登录失败,显示错误提示
if (!error.response) errorMessage.value = "登录失败,请检查网络连接"; if (!error.response)
errorMessage.value = '登录失败,请检查网络连接'
else if (error.response.status === 401) else if (error.response.status === 401)
errorMessage.value = "登录失败,请检查用户名和密码是否正确"; errorMessage.value = '登录失败,请检查用户名和密码是否正确'
else if (error.response.status === 403) else if (error.response.status === 403)
errorMessage.value = "登录失败,您没有权限访问"; errorMessage.value = '登录失败,您没有权限访问'
else if (error.response.status === 500) errorMessage.value = "登录失败,服务器错误"; else if (error.response.status === 500)
errorMessage.value = '登录失败,服务器错误'
else else
errorMessage.value = `登录失败 ${error.response.status},请检查用户名和密码是否正确`; errorMessage.value = `登录失败 ${error.response.status},请检查用户名和密码是否正确`
}); })
}; }
// 自动登录 // 自动登录
onMounted(() => { onMounted(() => {
// 从Vuex Store中获取token和remember状态 // 从Vuex Store中获取token和remember状态
const token = store.state.auth.token; const token = store.state.auth.token
const remember = store.state.auth.remember; const remember = store.state.auth.remember
// 如果token存在,且保持登录状态为true,则跳转到首页 // 如果token存在,且保持登录状态为true,则跳转到首页
if (token && remember) { if (token && remember) {
router.push("/"); router.push('/')
} else {
// 获取背景图片
fetchBackgroundImage();
} }
}); else {
// 获取背景图片
fetchBackgroundImage()
}
})
</script> </script>
<template> <template>
@@ -107,8 +111,8 @@ onMounted(() => {
aspect-ratio="4/3" aspect-ratio="4/3"
:src="backgroundImageUrl" :src="backgroundImageUrl"
class="w-full h-full overflow-hidden" class="w-full h-full overflow-hidden"
@load="isImageLoaded = true"
cover cover
@load="isImageLoaded = true"
> >
<div class="auth-wrapper d-flex align-center justify-center pa-4"> <div class="auth-wrapper d-flex align-center justify-center pa-4">
<VCard <VCard
@@ -130,7 +134,10 @@ onMounted(() => {
</VCardItem> </VCardItem>
<VCardText> <VCardText>
<VForm @submit.prevent="() => {}" ref="refForm"> <VForm
ref="refForm"
@submit.prevent="() => {}"
>
<VRow> <VRow>
<!-- username --> <!-- username -->
<VCol cols="12"> <VCol cols="12">
@@ -155,19 +162,30 @@ onMounted(() => {
@click:append-inner="isPasswordVisible = !isPasswordVisible" @click:append-inner="isPasswordVisible = !isPasswordVisible"
/> />
<div v-if="errorMessage" class="text-error mt-1"> <div
v-if="errorMessage"
class="text-error mt-1"
>
{{ errorMessage }} {{ errorMessage }}
</div> </div>
<!-- remember me checkbox --> <!-- remember me checkbox -->
<div <div class="d-flex align-center justify-space-between flex-wrap mt-1 mb-4">
class="d-flex align-center justify-space-between flex-wrap mt-1 mb-4" <VCheckbox
> v-model="form.remember"
<VCheckbox v-model="form.remember" label="保持登录" required /> label="保持登录"
required
/>
</div> </div>
<!-- login button --> <!-- login button -->
<VBtn block type="submit" @click="login"> 登录 </VBtn> <VBtn
block
type="submit"
@click="login"
>
登录
</VBtn>
</VCol> </VCol>
</VRow> </VRow>
</VForm> </VForm>
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import PluginCardListView from "@/views/setting/PluginCardListView.vue"; import PluginCardListView from '@/views/plugin/PluginCardListView.vue'
</script> </script>
<template> <template>
+97 -25
View File
@@ -1,15 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'; import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
</script> </script>
<template> <template>
<div> <div>
<MediaCardSlideView apipath="tmdb/trending"> <MediaCardSlideView apipath="tmdb/trending">
<template #title="{ loaded }"> <template #title="{ loaded }">
<div class="slider-header ms-1" v-if="loaded"> <div
<RouterLink to="/browse/tmdb/trending" class="slider-title"> v-if="loaded"
class="slider-header ms-1"
>
<RouterLink
to="/browse/tmdb/trending"
class="slider-title"
>
<span>流行趋势</span> <span>流行趋势</span>
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" /> <VIcon
icon="mdi-arrow-right-circle-outline"
class="ms-1"
/>
</RouterLink> </RouterLink>
</div> </div>
</template> </template>
@@ -17,10 +26,19 @@ import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue';
<MediaCardSlideView apipath="tmdb/movies"> <MediaCardSlideView apipath="tmdb/movies">
<template #title="{ loaded }"> <template #title="{ loaded }">
<div class="slider-header mt-3 ms-1" v-if="loaded"> <div
<RouterLink to="/browse/tmdb/movies" class="slider-title"> v-if="loaded"
class="slider-header mt-3 ms-1"
>
<RouterLink
to="/browse/tmdb/movies"
class="slider-title"
>
<span>热门电影</span> <span>热门电影</span>
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" /> <VIcon
icon="mdi-arrow-right-circle-outline"
class="ms-1"
/>
</RouterLink> </RouterLink>
</div> </div>
</template> </template>
@@ -28,10 +46,19 @@ import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue';
<MediaCardSlideView apipath="tmdb/tvs"> <MediaCardSlideView apipath="tmdb/tvs">
<template #title="{ loaded }"> <template #title="{ loaded }">
<div class="slider-header mt-3 ms-1" v-if="loaded"> <div
<RouterLink to="/browse/tmdb/tvs" class="slider-title"> v-if="loaded"
class="slider-header mt-3 ms-1"
>
<RouterLink
to="/browse/tmdb/tvs"
class="slider-title"
>
<span>热门电视剧</span> <span>热门电视剧</span>
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" /> <VIcon
icon="mdi-arrow-right-circle-outline"
class="ms-1"
/>
</RouterLink> </RouterLink>
</div> </div>
</template> </template>
@@ -39,10 +66,19 @@ import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue';
<MediaCardSlideView apipath="douban/movies"> <MediaCardSlideView apipath="douban/movies">
<template #title="{ loaded }"> <template #title="{ loaded }">
<div class="slider-header mt-3 ms-1" v-if="loaded"> <div
<RouterLink to="/browse/douban/movies" class="slider-title"> v-if="loaded"
class="slider-header mt-3 ms-1"
>
<RouterLink
to="/browse/douban/movies"
class="slider-title"
>
<span>最新电影</span> <span>最新电影</span>
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" /> <VIcon
icon="mdi-arrow-right-circle-outline"
class="ms-1"
/>
</RouterLink> </RouterLink>
</div> </div>
</template> </template>
@@ -50,10 +86,19 @@ import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue';
<MediaCardSlideView apipath="douban/tvs"> <MediaCardSlideView apipath="douban/tvs">
<template #title="{ loaded }"> <template #title="{ loaded }">
<div class="slider-header mt-3 ms-1" v-if="loaded"> <div
<RouterLink to="/browse/douban/tvs" class="slider-title"> v-if="loaded"
class="slider-header mt-3 ms-1"
>
<RouterLink
to="/browse/douban/tvs"
class="slider-title"
>
<span>最新电视剧</span> <span>最新电视剧</span>
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" /> <VIcon
icon="mdi-arrow-right-circle-outline"
class="ms-1"
/>
</RouterLink> </RouterLink>
</div> </div>
</template> </template>
@@ -61,10 +106,19 @@ import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue';
<MediaCardSlideView apipath="douban/movie_top250"> <MediaCardSlideView apipath="douban/movie_top250">
<template #title="{ loaded }"> <template #title="{ loaded }">
<div class="slider-header mt-3 ms-1" v-if="loaded"> <div
<RouterLink to="/browse/douban/movie_top250" class="slider-title"> v-if="loaded"
class="slider-header mt-3 ms-1"
>
<RouterLink
to="/browse/douban/movie_top250"
class="slider-title"
>
<span>电影TOP250</span> <span>电影TOP250</span>
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" /> <VIcon
icon="mdi-arrow-right-circle-outline"
class="ms-1"
/>
</RouterLink> </RouterLink>
</div> </div>
</template> </template>
@@ -72,10 +126,19 @@ import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue';
<MediaCardSlideView apipath="douban/tv_weekly_chinese"> <MediaCardSlideView apipath="douban/tv_weekly_chinese">
<template #title="{ loaded }"> <template #title="{ loaded }">
<div class="slider-header mt-3 ms-1" v-if="loaded"> <div
<RouterLink to="/browse/douban/tv_weekly_chinese" class="slider-title"> v-if="loaded"
class="slider-header mt-3 ms-1"
>
<RouterLink
to="/browse/douban/tv_weekly_chinese"
class="slider-title"
>
<span>国产剧集榜</span> <span>国产剧集榜</span>
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" /> <VIcon
icon="mdi-arrow-right-circle-outline"
class="ms-1"
/>
</RouterLink> </RouterLink>
</div> </div>
</template> </template>
@@ -83,10 +146,19 @@ import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue';
<MediaCardSlideView apipath="douban/tv_weekly_global"> <MediaCardSlideView apipath="douban/tv_weekly_global">
<template #title="{ loaded }"> <template #title="{ loaded }">
<div class="slider-header mt-3 ms-1" v-if="loaded"> <div
<RouterLink to="/browse/douban/tv_weekly_global" class="slider-title"> v-if="loaded"
class="slider-header mt-3 ms-1"
>
<RouterLink
to="/browse/douban/tv_weekly_global"
class="slider-title"
>
<span>全球剧集榜</span> <span>全球剧集榜</span>
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" /> <VIcon
icon="mdi-arrow-right-circle-outline"
class="ms-1"
/>
</RouterLink> </RouterLink>
</div> </div>
</template> </template>
+9 -5
View File
@@ -1,17 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import TorrentCardListView from "@/views/discover/TorrentCardListView.vue"; import TorrentCardListView from '@/views/discover/TorrentCardListView.vue'
// 路由参数 // 路由参数
const route = useRoute(); const route = useRoute()
// 查询TMDBID或标题 // 查询TMDBID或标题
const keyword = route.query?.keyword?.toString() ?? ""; const keyword = route.query?.keyword?.toString() ?? ''
// 查询类型 // 查询类型
const type = route.query?.type?.toString() ?? ""; const type = route.query?.type?.toString() ?? ''
</script> </script>
<template> <template>
<div> <div>
<TorrentCardListView :keyword="keyword" :type="type" /> <TorrentCardListView
:keyword="keyword"
:type="type"
/>
</div> </div>
</template> </template>
+73
View File
@@ -0,0 +1,73 @@
<script lang="ts" setup>
import { useRoute } from 'vue-router'
import AccountSettingAccount from '@/views/setting/AccountSettingAccount.vue'
import AccountSettingNotification from '@/views/setting/AccountSettingNotification.vue'
import AccountSettingRule from '@/views/setting/AccountSettingRule.vue'
import AccountSettingSite from '@/views/setting/AccountSettingSite.vue'
import AccountSettingWords from '@/views/setting/AccountSettingWords.vue'
const route = useRoute()
const activeTab = ref(route.params.tab)
// tabs
const tabs = [
{ title: '用户', icon: 'mdi-account', tab: 'account' },
{ title: '站点', icon: 'mdi-web', tab: 'site' },
{ title: '规则', icon: 'mdi-filter-cog', tab: 'filter' },
{ title: '通知', icon: 'mdi-bell', tab: 'notification' },
{ title: '自定义词表', icon: 'mdi-file-word-box', tab: 'words' },
]
</script>
<template>
<div>
<VTabs
v-model="activeTab"
show-arrows
>
<VTab
v-for="item in tabs"
:key="item.icon"
:value="item.tab"
>
<VIcon
size="20"
start
:icon="item.icon"
/>
{{ item.title }}
</VTab>
</VTabs>
<VDivider />
<VWindow
v-model="activeTab"
class="mt-5"
>
<!-- Account -->
<VWindowItem value="account" transition="scale-transition">
<AccountSettingAccount />
</VWindowItem>
<!-- System -->
<VWindowItem value="site" transition="scale-transition">
<AccountSettingSite />
</VWindowItem>
<!-- Notification -->
<VWindowItem value="filter" transition="scale-transition">
<AccountSettingRule />
</VWindowItem>
<!-- Notification -->
<VWindowItem value="notification" transition="scale-transition">
<AccountSettingNotification />
</VWindowItem>
<!-- Words -->
<VWindowItem value="words" transition="scale-transition">
<AccountSettingWords />
</VWindowItem>
</VWindow>
</div>
</template>
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import SiteListView from "@/views/site/SiteCardListView.vue"; import SiteListView from '@/views/site/SiteCardListView.vue'
</script> </script>
<template> <template>
+1 -2
View File
@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'; import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
</script> </script>
<template> <template>
+1 -2
View File
@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'; import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
</script> </script>
<template> <template>
+1 -1
View File
@@ -11,7 +11,7 @@ const alertTypeIcon = {
const modifiedAliases = Object.assign(aliases, alertTypeIcon) const modifiedAliases = Object.assign(aliases, alertTypeIcon)
export const iconify = { export const iconify = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
component: (props: any) => h(Icon, props), component: (props: any) => h(Icon, props),
} }
+1
View File
@@ -5,6 +5,7 @@ import * as labsComponents from 'vuetify/labs/components'
import defaults from './defaults' import defaults from './defaults'
import { icons } from './icons' import { icons } from './icons'
import theme from './theme' import theme from './theme'
// Styles // Styles
import '@core/scss/template/libs/vuetify/index.scss' import '@core/scss/template/libs/vuetify/index.scss'
import 'vuetify/styles' import 'vuetify/styles'
+4 -4
View File
@@ -1,8 +1,8 @@
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
scrollBehavior(to, from, savedPosition) { scrollBehavior() {
// 始终滚动到顶部 // 始终滚动到顶部
return { top: 0 } return { top: 0 }
}, },
@@ -83,8 +83,8 @@ const router = createRouter({
}, },
}, },
{ {
path: 'account-setting', path: 'setting',
component: () => import('../pages/account-setting.vue'), component: () => import('../pages/setting.vue'),
meta: { meta: {
requiresAuth: true, requiresAuth: true,
}, },
+13 -14
View File
@@ -1,17 +1,16 @@
import { Module } from 'vuex'; import type { Module } from 'vuex'
// 定义状态类型 // 定义状态类型
interface AuthState { interface AuthState {
token: string | null; token: string | null
remember: boolean; remember: boolean
} }
// 定义根状态类型 // 定义根状态类型
interface RootState { interface RootState {
auth: AuthState; auth: AuthState
} }
// 导出模块 // 导出模块
const authModule: Module<AuthState, RootState> = { const authModule: Module<AuthState, RootState> = {
namespaced: true, namespaced: true,
@@ -21,30 +20,30 @@ const authModule: Module<AuthState, RootState> = {
}, },
mutations: { mutations: {
setToken(state, token: string) { setToken(state, token: string) {
state.token = token; state.token = token
}, },
clearToken(state) { clearToken(state) {
state.token = null; state.token = null
}, },
setRemember(state, remember: boolean) { setRemember(state, remember: boolean) {
state.remember = remember; state.remember = remember
}, },
}, },
actions: { actions: {
updateToken({ commit }, token: string) { updateToken({ commit }, token: string) {
commit('setToken', token); commit('setToken', token)
}, },
clearToken({ commit },) { clearToken({ commit }) {
commit('clearToken'); commit('clearToken')
}, },
updateRemember({ commit }, remember: boolean) { updateRemember({ commit }, remember: boolean) {
commit('setRemember', remember); commit('setRemember', remember)
}, },
}, },
getters: { getters: {
getToken: state => state.token, getToken: state => state.token,
getRemember: state => state.remember, getRemember: state => state.remember,
},
} }
};
export default authModule; export default authModule
+5 -5
View File
@@ -1,6 +1,6 @@
import { createStore } from 'vuex'; import { createStore } from 'vuex'
import createPersistedState from 'vuex-persistedstate'; import createPersistedState from 'vuex-persistedstate'
import authModule from './auth'; import authModule from './auth'
const store = createStore({ const store = createStore({
modules: { modules: {
@@ -14,6 +14,6 @@ const store = createStore({
key: 'moviepilot', // 存储的键名 key: 'moviepilot', // 存储的键名
}), }),
], ],
}); })
export default store; export default store
+34
View File
@@ -50,3 +50,37 @@
max-height: calc(100% - env(safe-area-inset-top)); max-height: calc(100% - env(safe-area-inset-top));
width: 100%; width: 100%;
} }
/* router view transition fade-slide */
.fade-slide-leave-active,
.fade-slide-enter-active {
transition: all 0.3s;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateX(30px);
}
/* router view transition fade-slide */
.fade-slide-leave-active,
.fade-slide-enter-active {
transition: all 0.6s;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateX(-45px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateX(45px);
}
@@ -1,352 +0,0 @@
<script lang="ts" setup>
import { requiredValidator } from "@/@validators";
import api from "@/api";
import { User } from "@/api/types";
import avatar1 from "@images/avatars/avatar-1.png";
import { useToast } from "vue-toast-notification";
const isNewPasswordVisible = ref(false);
const isConfirmPasswordVisible = ref(false);
const isPasswordVisible = ref(false);
const newPassword = ref("");
const confirmPassword = ref("");
// 提示框
const $toast = useToast();
const refInputEl = ref<HTMLElement>();
// 新增用户窗口
const addUserDialog = ref(false);
// 新增用户表单
const userForm = reactive({
name: "",
password: "",
email: "",
});
// 当前用户信息
const accountInfo = ref<User>({
id: 0,
name: "",
password: "",
email: "",
is_active: false,
is_superuser: false,
avatar: "",
});
// 所有用户信息
const allUsers = ref<User[]>([]);
// changeAvatar function
const changeAvatar = (file: Event) => {
const fileReader = new FileReader();
const { files } = file.target as HTMLInputElement;
if (files && files.length > 0) {
fileReader.readAsDataURL(files[0]);
fileReader.onload = () => {
if (typeof fileReader.result === "string") {
accountInfo.value.avatar = fileReader.result;
saveAccountInfo();
}
};
}
};
// reset avatar image
const resetAvatar = () => {
accountInfo.value.avatar = avatar1;
};
// 调用API,加载当前用户数据
const loadAccountInfo = async () => {
try {
const user: User = await api.get(`user/current`);
accountInfo.value = user;
if (!accountInfo.value.avatar) accountInfo.value.avatar = avatar1;
} catch (error) {
console.log(error);
}
};
// 保存用户信息
const saveAccountInfo = async () => {
if (newPassword.value || confirmPassword.value) {
if (newPassword.value !== confirmPassword.value) {
$toast.error("两次输入的密码不一致");
return;
}
accountInfo.value.password = newPassword.value;
}
try {
const result: { [key: string]: any } = await api.put(`user`, accountInfo.value);
if (result.success) {
$toast.success("用户信息保存成功!");
} else {
$toast.error(`用户信息保存失败:${result.message}`);
}
} catch (error) {
console.log(error);
}
};
// 调用API,查询所有用户
const loadAllUsers = async () => {
try {
const result: User[] = await api.get(`/user`);
allUsers.value = result;
} catch (error) {
console.log(error);
}
};
// 删除用户
const deleteUser = async (user: User) => {
try {
const result: { [key: string]: any } = await api.delete(`user/${user.name}`);
if (result.success) {
$toast.success("用户删除成功!");
loadAllUsers();
} else {
$toast.error(`用户删除失败:${result.message}`);
}
} catch (error) {
console.log(error);
}
};
// 冻结用户
const deactivateUser = async (user: User) => {
try {
user.is_active = !user.is_active;
const result: { [key: string]: any } = await api.put(`user`, user);
if (result.success) {
$toast.success("用户冻结成功!");
loadAllUsers();
} else {
$toast.error(`用户冻结失败:${result.message}`);
}
} catch (error) {
console.log(error);
}
};
// 新增用户
const addUser = async () => {
try {
const result: { [key: string]: any } = await api.post(`user`, userForm);
if (result.success) {
$toast.success("用户新增成功!");
loadAllUsers();
addUserDialog.value = false;
} else {
$toast.error(`用户新增失败:${result.message}`);
}
} catch (error) {
console.log(error);
}
};
// 加载当前用户数据
onMounted(() => {
loadAccountInfo();
loadAllUsers();
});
</script>
<template>
<VRow>
<VCol cols="12">
<VCard title="个人信息">
<VCardText class="d-flex">
<!-- 👉 Avatar -->
<VAvatar rounded="lg" size="100" class="me-6" :image="accountInfo.avatar" />
<!-- 👉 Upload Photo -->
<form class="d-flex flex-column justify-center gap-5">
<div class="d-flex flex-wrap gap-2">
<VBtn color="primary" @click="refInputEl?.click()">
<VIcon icon="mdi-cloud-upload-outline" class="d-sm-none" />
<span class="d-none d-sm-block">上传头像</span>
</VBtn>
<input
ref="refInputEl"
type="file"
name="file"
accept=".jpeg,.png,.jpg,GIF"
hidden
@input="changeAvatar"
/>
<VBtn type="reset" color="error" variant="tonal" @click="resetAvatar">
<span class="d-none d-sm-block">重置</span>
<VIcon icon="mdi-refresh" class="d-sm-none" />
</VBtn>
</div>
<p class="text-body-1 mb-0">允许 JPGGIF PNG 格式 最大尽寸 800K</p>
</form>
</VCardText>
<VDivider />
<VCardText>
<!-- 👉 Form -->
<VForm class="mt-6">
<VRow>
<!-- 👉 Name -->
<VCol md="6" cols="12">
<VTextField readonly v-model="accountInfo.name" label="用户名" />
</VCol>
<!-- 👉 Email -->
<VCol cols="12" md="6">
<VTextField v-model="accountInfo.email" label="邮箱" type="email" />
</VCol>
<VCol cols="12" md="6">
<!-- 👉 new password -->
<VTextField
v-model="newPassword"
:type="isNewPasswordVisible ? 'text' : 'password'"
:append-inner-icon="
isNewPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'
"
label="新密码"
@click:append-inner="isNewPasswordVisible = !isNewPasswordVisible"
autocomplete="new-password"
/>
</VCol>
<VCol cols="12" md="6">
<!-- 👉 confirm password -->
<VTextField
v-model="confirmPassword"
:type="isConfirmPasswordVisible ? 'text' : 'password'"
:append-inner-icon="
isConfirmPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'
"
label="确认新密码"
@click:append-inner="
isConfirmPasswordVisible = !isConfirmPasswordVisible
"
/>
</VCol>
<!-- 👉 Form Actions -->
<VCol cols="12" class="d-flex flex-wrap gap-4">
<VBtn @click="saveAccountInfo">保存</VBtn>
</VCol>
</VRow>
</VForm>
</VCardText>
</VCard>
</VCol>
<VCol cols="12" v-if="accountInfo.is_superuser">
<!-- 👉 Accounts -->
<VCard title="所有用户">
<template #append>
<IconBtn @click.stop="addUserDialog = true">
<VIcon icon="mdi-plus" />
</IconBtn>
</template>
<VTable class="text-no-wrap">
<thead>
<tr>
<th scope="col">用户名</th>
<th scope="col">邮箱</th>
<th scope="col">状态</th>
<th scope="col">管理员</th>
<th scope="col" class="w-5"></th>
</tr>
</thead>
<tbody>
<tr v-for="user in allUsers" :key="user.name">
<td>
{{ user.name }}
</td>
<td>{{ user.email }}</td>
<td>
<VChip color="success" text-color="white" v-if="user.is_active"
>激活</VChip
>
<VChip color="error" text-color="white" v-else>冻结</VChip>
</td>
<td>{{ user.is_superuser ? "是" : "否" }}</td>
<td>
<IconBtn v-show="!user.is_superuser">
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="deactivateUser(user)">
<template #prepend>
<VIcon icon="mdi-lock"></VIcon>
</template>
<VListItemTitle>{{
user.is_active ? "冻结" : "解冻"
}}</VListItemTitle>
</VListItem>
<VListItem
variant="plain"
base-color="error"
@click="deleteUser(user)"
>
<template #prepend>
<VIcon icon="mdi-delete"></VIcon>
</template>
<VListItemTitle>删除</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</td>
</tr>
</tbody>
</VTable>
</VCard>
</VCol>
</VRow>
<!-- 站点编辑弹窗 -->
<VDialog v-model="addUserDialog" max-width="800" persistent>
<!-- Dialog Content -->
<VCard title="新增用户">
<VCardText>
<VForm @submit.prevent="() => {}">
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="userForm.name"
label="用户名"
:rules="[requiredValidator]"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="userForm.password"
label="密码"
:rules="[requiredValidator]"
:type="isPasswordVisible ? 'text' : 'password'"
:append-inner-icon="
isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'
"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="userForm.email" label="邮箱" />
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VBtn @click="addUserDialog = false"> 取消 </VBtn>
<VSpacer />
<VBtn @click="addUser"> 确定 </VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
@@ -1,87 +0,0 @@
<script lang="ts" setup>
import api from "@/api";
import { NotificationSwitch } from "@/api/types";
import { useToast } from "vue-toast-notification";
const messagemTypes = ref<NotificationSwitch[]>([]);
// 提示框
const $toast = useToast();
// 调用API查询消息开关
const loadNotificationSwitchs = async () => {
try {
const result: NotificationSwitch[] = await api.get("message/switchs");
messagemTypes.value = result;
} catch (error) {
console.log(error);
}
};
// 调用API保存消息开关
const saveNotificationSwitchs = async () => {
try {
const result: { [key: string]: any } = await api.post(
"message/switchs",
messagemTypes.value
);
if (result.success) {
$toast.success("保存通知消息设置成功");
} else {
$toast.error("保存通知消息设置失败!");
}
messagemTypes.value = messagemTypes.value;
} catch (error) {
console.log(error);
}
};
onMounted(() => {
loadNotificationSwitchs();
});
</script>
<template>
<VCard title="消息通知">
<VCardText> 对应消息类型只会发送给选中的消息渠道 </VCardText>
<VTable class="text-no-wrap">
<thead>
<tr>
<th scope="col">消息类型</th>
<th scope="col">微信</th>
<th scope="col">Telegram</th>
<th scope="col">Slack</th>
</tr>
</thead>
<tbody>
<tr v-for="message in messagemTypes" :key="message.mtype">
<td>
{{ message.mtype }}
</td>
<td>
<VCheckbox v-model="message.wechat" />
</td>
<td>
<VCheckbox v-model="message.telegram" />
</td>
<td>
<VCheckbox v-model="message.slack" />
</td>
</tr>
<tr v-if="messagemTypes.length === 0">
<td colspan="4" class="text-center">没有设置任何通知渠道</td>
</tr>
</tbody>
</VTable>
<VDivider />
<VCardText>
<VForm @submit.prevent="() => {}">
<div class="d-flex flex-wrap gap-4 mt-4">
<VBtn mtype="submit" @click="saveNotificationSwitchs"> 保存 </VBtn>
</div>
</VForm>
</VCardText>
</VCard>
</template>
@@ -1,135 +0,0 @@
<script lang="ts" setup>
import api from "@/api";
import { Site } from "@/api/types";
import { useToast } from "vue-toast-notification";
// 提示框
const $toast = useToast();
// 选中站点
const selectedSites = ref<number[]>([]);
// 所有站点
const allSites = ref<Site[]>([]);
// 站点重置
const isConfirmResetSites = ref(false);
// 站点重置按钮文本
const resetSitesText = ref("重置站点数据");
// 站点重置按钮可用状态
const resetSitesDisabled = ref(false);
// 查询所有站点
const querySites = async () => {
try {
const data: Site[] = await api.get("site");
// 过滤站点,只有启用的站点才显示
allSites.value = data.filter((item) => item.is_active);
querySelectedSites();
} catch (error) {
console.log(error);
}
};
// 查询用户选中的站点
const querySelectedSites = async () => {
try {
const result: { [key: string]: any } = await api.get("system/setting/IndexerSites");
selectedSites.value = result.data?.value;
} catch (error) {
console.log(error);
}
};
// 保存用户选中的站点
const saveSelectedSites = async () => {
try {
// 用户名密码
const result: { [key: string]: any } = await api.post(
"system/setting/IndexerSites",
selectedSites.value
);
if (result.success) {
$toast.success("索引站点保存成功");
} else {
$toast.error("索引站点保存失败!");
}
} catch (error) {
console.log(error);
}
};
// 重置站点
const resetSites = async () => {
try {
resetSitesDisabled.value = true;
resetSitesText.value = "正在重置...";
const result: { [key: string]: any } = await api.get("site/reset");
if (result.success) {
$toast.success("站点重置成功,请等待CookieCloud同步完成!");
querySites();
} else {
$toast.error("站点重置失败!");
}
resetSitesDisabled.value = false;
resetSitesText.value = "重置站点数据";
} catch (error) {
console.log(error);
}
};
onMounted(() => {
querySites();
});
</script>
<template>
<VRow>
<VCol cols="12">
<VCard title="索引站点">
<VCardSubtitle> 只有选中的站点才会在搜索和订阅中使用 </VCardSubtitle>
<VCardItem>
<VChipGroup v-model="selectedSites" column multiple>
<VChip
filter
variant="outlined"
v-for="site in allSites"
:key="site.id"
:value="site.id"
>
{{ site.name }}
</VChip>
</VChipGroup>
</VCardItem>
<VCardItem>
<VBtn type="submit" @click="saveSelectedSites"> 保存 </VBtn>
</VCardItem>
</VCard>
</VCol>
<VCol cols="12">
<VCard title="站点重置">
<VCardText>
<div>
<VCheckbox
v-model="isConfirmResetSites"
label="确认删除所有站点数据并重新同步"
/>
</div>
<VBtn
:disabled="!isConfirmResetSites || resetSitesDisabled"
color="error"
class="mt-3"
@click="resetSites"
>
{{ resetSitesText }}
</VBtn>
</VCardText>
</VCard>
</VCol>
</VRow>
</template>
+45 -30
View File
@@ -1,54 +1,56 @@
<script setup lang="ts"> <script setup lang="ts">
import api from "@/api"; import api from '@/api'
import { MediaStatistic } from "@/api/types"; import type { MediaStatistic } from '@/api/types'
const statistics = ref([ const statistics = ref([
{ {
title: "", title: '',
stats: "", stats: '',
icon: "", icon: '',
color: "", color: '',
}, },
]); ])
// 调用API加载媒体统计数据 // 调用API加载媒体统计数据
const loadMediaStatistic = async () => { async function loadMediaStatistic() {
try { try {
const res: MediaStatistic = await api.get("dashboard/statistic"); const res: MediaStatistic = await api.get('dashboard/statistic')
statistics.value = [ statistics.value = [
{ {
title: "电影", title: '电影',
stats: res.movie_count.toLocaleString(), stats: res.movie_count.toLocaleString(),
icon: "mdi-movie-roll", icon: 'mdi-movie-roll',
color: "primary", color: 'primary',
}, },
{ {
title: "电视剧", title: '电视剧',
stats: res.tv_count.toLocaleString(), stats: res.tv_count.toLocaleString(),
icon: "mdi-television-box", icon: 'mdi-television-box',
color: "success", color: 'success',
}, },
{ {
title: "剧集", title: '剧集',
stats: res.episode_count.toLocaleString(), stats: res.episode_count.toLocaleString(),
icon: "mdi-television-classic", icon: 'mdi-television-classic',
color: "warning", color: 'warning',
}, },
{ {
title: "用户", title: '用户',
stats: res.user_count.toLocaleString(), stats: res.user_count.toLocaleString(),
icon: "mdi-account", icon: 'mdi-account',
color: "info", color: 'info',
}, },
]; ]
} catch (e) { }
console.log(e); catch (e) {
console.log(e)
}
} }
};
onMounted(() => { onMounted(() => {
loadMediaStatistic(); loadMediaStatistic()
}); })
</script> </script>
<template> <template>
@@ -59,11 +61,24 @@ onMounted(() => {
<VCardText> <VCardText>
<VRow> <VRow>
<VCol v-for="item in statistics" :key="item.title" cols="6" sm="3"> <VCol
v-for="item in statistics"
:key="item.title"
cols="6"
sm="3"
>
<div class="d-flex align-center"> <div class="d-flex align-center">
<div class="me-3"> <div class="me-3">
<VAvatar :color="item.color" rounded size="42" class="elevation-1"> <VAvatar
<VIcon size="24" :icon="item.icon" /> :color="item.color"
rounded
size="42"
class="elevation-1"
>
<VIcon
size="24"
:icon="item.icon"
/>
</VAvatar> </VAvatar>
</div> </div>
+46 -25
View File
@@ -1,42 +1,45 @@
<script lang="ts" setup> <script lang="ts" setup>
import { formatSeconds } from "@/@core/utils/formatters"; import { formatSeconds } from '@/@core/utils/formatters'
import api from "@/api"; import api from '@/api'
import { Process } from "@/api/types"; import type { Process } from '@/api/types'
// 表头 // 表头
const headers = ["进程ID", "进程名称", "运行时间", "内存占用"]; const headers = ['进程ID', '进程名称', '运行时间', '内存占用']
// 数据列表 // 数据列表
const processList = ref<Process[]>([]); const processList = ref<Process[]>([])
// 定时器 // 定时器
let refreshTimer: NodeJS.Timer | null = null; let refreshTimer: NodeJS.Timer | null = null
// 调用API加载数据 // 调用API加载数据
const loadProcessList = async () => { async function loadProcessList() {
try { try {
const res: Process[] = await api.get("dashboard/processes"); const res: Process[] = await api.get('dashboard/processes')
processList.value = res;
} catch (e) { processList.value = res
console.log(e); }
catch (e) {
console.log(e)
}
} }
};
onMounted(() => { onMounted(() => {
loadProcessList(); loadProcessList()
// 启动定时器 // 启动定时器
refreshTimer = setInterval(() => { refreshTimer = setInterval(() => {
loadProcessList(); loadProcessList()
}, 5000); }, 5000)
}); })
// 组件卸载时停止定时器 // 组件卸载时停止定时器
onUnmounted(() => { onUnmounted(() => {
if (refreshTimer) { if (refreshTimer) {
clearInterval(refreshTimer); clearInterval(refreshTimer)
refreshTimer = null; refreshTimer = null
} }
}); })
</script> </script>
<template> <template>
@@ -51,20 +54,38 @@ onUnmounted(() => {
> >
<thead> <thead>
<tr> <tr>
<th v-for="header in headers" :key="header" :id="header"> <th
v-for="header in headers"
:id="header"
:key="header"
>
{{ header }} {{ header }}
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="row in processList" :key="row.pid"> <tr
<td class="text-sm" v-text="row.pid" /> v-for="row in processList"
:key="row.pid"
>
<td
class="text-sm"
v-text="row.pid"
/>
<!-- name --> <!-- name -->
<td> <td>
<h6 class="text-sm font-weight-medium">{{ row.name }}</h6> <h6 class="text-sm font-weight-medium">
{{ row.name }}
</h6>
</td> </td>
<td class="text-sm" v-text="formatSeconds(row.run_time)" /> <td
<td class="text-sm" v-text="`${row.memory} MB`" /> class="text-sm"
v-text="formatSeconds(row.run_time)"
/>
<td
class="text-sm"
v-text="`${row.memory} MB`"
/>
</tr> </tr>
</tbody> </tbody>
</VTable> </VTable>
+40 -22
View File
@@ -1,38 +1,41 @@
<script setup lang="ts"> <script setup lang="ts">
import api from "@/api"; import api from '@/api'
import { ScheduleInfo } from "@/api/types"; import type { ScheduleInfo } from '@/api/types'
// 定时服务列表 // 定时服务列表
const schedulerList = ref<ScheduleInfo[]>([]); const schedulerList = ref<ScheduleInfo[]>([])
// 定时器 // 定时器
let refreshTimer: NodeJS.Timer | null = null; let refreshTimer: NodeJS.Timer | null = null
// 调用API加载定时服务列表 // 调用API加载定时服务列表
const loadSchedulerList = async () => { async function loadSchedulerList() {
try { try {
const res: ScheduleInfo[] = await api.get("dashboard/schedule"); const res: ScheduleInfo[] = await api.get('dashboard/schedule')
schedulerList.value = res;
} catch (e) { schedulerList.value = res
console.log(e); }
catch (e) {
console.log(e)
}
} }
};
onMounted(() => { onMounted(() => {
loadSchedulerList(); loadSchedulerList()
// 启动定时器 // 启动定时器
refreshTimer = setInterval(() => { refreshTimer = setInterval(() => {
loadSchedulerList(); loadSchedulerList()
}, 60000); }, 60000)
}); })
// 组件卸载时停止定时器 // 组件卸载时停止定时器
onUnmounted(() => { onUnmounted(() => {
if (refreshTimer) { if (refreshTimer) {
clearInterval(refreshTimer); clearInterval(refreshTimer)
refreshTimer = null; refreshTimer = null
} }
}); })
</script> </script>
<template> <template>
@@ -42,10 +45,21 @@ onUnmounted(() => {
</VCardItem> </VCardItem>
<VCardText> <VCardText>
<VList class="card-list" height="250"> <VList
<VListItem v-for="item in schedulerList" :key="item.id"> class="card-list"
height="250"
>
<VListItem
v-for="item in schedulerList"
:key="item.id"
>
<template #prepend> <template #prepend>
<VAvatar size="40" variant="tonal" color="" class="me-3"> <VAvatar
size="40"
variant="tonal"
color=""
class="me-3"
>
{{ item.name[0] }} {{ item.name[0] }}
</VAvatar> </VAvatar>
</template> </template>
@@ -54,7 +68,9 @@ onUnmounted(() => {
<span class="text-sm font-weight-medium">{{ item.name }}</span> <span class="text-sm font-weight-medium">{{ item.name }}</span>
</VListItemTitle> </VListItemTitle>
<VListItemSubtitle class="text-xs"> {{ item.next_run }}</VListItemSubtitle> <VListItemSubtitle class="text-xs">
{{ item.next_run }}
</VListItemSubtitle>
<template #append> <template #append>
<div> <div>
@@ -65,7 +81,9 @@ onUnmounted(() => {
</template> </template>
</VListItem> </VListItem>
<VListItem v-if="schedulerList.length === 0"> <VListItem v-if="schedulerList.length === 0">
<VListItemTitle class="text-center">没有后台服务</VListItemTitle> <VListItemTitle class="text-center">
没有后台服务
</VListItemTitle>
</VListItem> </VListItem>
</VList> </VList>
</VCardText> </VCardText>
+50 -33
View File
@@ -1,76 +1,83 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatFileSize } from "@/@core/utils/formatters"; import { formatFileSize } from '@/@core/utils/formatters'
import api from "@/api"; import api from '@/api'
import { DownloaderInfo } from "@/api/types"; import type { DownloaderInfo } from '@/api/types'
// 定时器 // 定时器
let refreshTimer: NodeJS.Timer | null = null; let refreshTimer: NodeJS.Timer | null = null
// 下载器信息 // 下载器信息
const downloadInfo = ref<DownloaderInfo>({ const downloadInfo = ref<DownloaderInfo>({
// 下载速度 // 下载速度
download_speed: 0, download_speed: 0,
// 上传速度 // 上传速度
upload_speed: 0, upload_speed: 0,
// 下载量 // 下载量
download_size: 0, download_size: 0,
// 上传量 // 上传量
upload_size: 0, upload_size: 0,
// 剩余空间 // 剩余空间
free_space: 0, free_space: 0,
}); })
// 显示项 // 显示项
const infoItems = ref([ const infoItems = ref([
{ {
avatar: "", avatar: '',
title: "", title: '',
amount: "", amount: '',
}, },
]); ])
// 调用API查询下载器数据 // 调用API查询下载器数据
const loadDownloaderInfo = async () => { async function loadDownloaderInfo() {
try { try {
const res: DownloaderInfo = await api.get("dashboard/downloader"); const res: DownloaderInfo = await api.get('dashboard/downloader')
downloadInfo.value = res;
downloadInfo.value = res
infoItems.value = [ infoItems.value = [
{ {
avatar: "mdi-cloud-upload", avatar: 'mdi-cloud-upload',
title: "总上传量", title: '总上传量',
amount: formatFileSize(res.upload_size), amount: formatFileSize(res.upload_size),
}, },
{ {
avatar: "mdi-download-box", avatar: 'mdi-download-box',
title: "总下载量", title: '总下载量',
amount: formatFileSize(res.download_size), amount: formatFileSize(res.download_size),
}, },
{ {
avatar: "mdi-content-save", avatar: 'mdi-content-save',
title: "磁盘剩余空间", title: '磁盘剩余空间',
amount: formatFileSize(res.free_space), amount: formatFileSize(res.free_space),
}, },
]; ]
} catch (e) { }
console.log(e); catch (e) {
console.log(e)
}
} }
};
onMounted(() => { onMounted(() => {
loadDownloaderInfo(); loadDownloaderInfo()
// 启动定时器 // 启动定时器
refreshTimer = setInterval(() => { refreshTimer = setInterval(() => {
loadDownloaderInfo(); loadDownloaderInfo()
}, 3000); }, 3000)
}); })
// 组件卸载时停止定时器 // 组件卸载时停止定时器
onUnmounted(() => { onUnmounted(() => {
if (refreshTimer) { if (refreshTimer) {
clearInterval(refreshTimer); clearInterval(refreshTimer)
refreshTimer = null; refreshTimer = null
} }
}); })
</script> </script>
<template> <template>
@@ -81,13 +88,23 @@ onUnmounted(() => {
<VCardText class="pt-4"> <VCardText class="pt-4">
<div> <div>
<p class="text-h5 me-2">{{ formatFileSize(downloadInfo.upload_speed) }}/s</p> <p class="text-h5 me-2">
<p class="text-h4 me-2">{{ formatFileSize(downloadInfo.download_speed) }}/s</p> {{ formatFileSize(downloadInfo.upload_speed) }}/s
</p>
<p class="text-h4 me-2">
{{ formatFileSize(downloadInfo.download_speed) }}/s
</p>
</div> </div>
<VList class="card-list mt-9"> <VList class="card-list mt-9">
<VListItem v-for="item in infoItems" :key="item.title"> <VListItem
v-for="item in infoItems"
:key="item.title"
>
<template #prepend> <template #prepend>
<VIcon rounded :icon="item.avatar" /> <VIcon
rounded
:icon="item.avatar"
/>
</template> </template>
<VListItemTitle class="text-sm font-weight-medium mb-1"> <VListItemTitle class="text-sm font-weight-medium mb-1">
+48 -27
View File
@@ -1,57 +1,78 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatFileSize } from "@/@core/utils/formatters"; import { useTheme } from 'vuetify'
import api from "@/api"; import { formatFileSize } from '@/@core/utils/formatters'
import trophy from "@images/misc/storage.png"; import api from '@/api'
import triangleDark from "@images/misc/triangle-dark.png"; import trophy from '@images/misc/storage.png'
import triangleLight from "@images/misc/triangle-light.png"; import triangleDark from '@images/misc/triangle-dark.png'
import { useTheme } from "vuetify"; import triangleLight from '@images/misc/triangle-light.png'
const { global } = useTheme()
const { global } = useTheme();
const triangleBg = computed(() => const triangleBg = computed(() =>
global.name.value === "light" ? triangleLight : triangleDark global.name.value === 'light' ? triangleLight : triangleDark,
); )
// 总存储空间 // 总存储空间
const storage = ref(0); const storage = ref(0)
// 已使用存储空间 // 已使用存储空间
const used = ref(0); const used = ref(0)
// 计算已使用存储空间百分比,精确到小数点后1位 // 计算已使用存储空间百分比,精确到小数点后1位
const usedPercent = computed(() => { const usedPercent = computed(() => {
return Math.round((used.value / storage.value) * 1000) / 10; return Math.round((used.value / storage.value) * 1000) / 10
}); })
// 调用API,查询存储空间 // 调用API,查询存储空间
const getStorage = async () => { async function getStorage() {
try { try {
const res: Storage = await api.get("dashboard/storage"); const res: Storage = await api.get('dashboard/storage')
storage.value = res.total_storage;
used.value = res.used_storage; storage.value = res.total_storage
} catch (e) { used.value = res.used_storage
console.log(e); }
catch (e) {
console.log(e)
}
} }
};
onMounted(() => { onMounted(() => {
getStorage(); getStorage()
}); })
</script> </script>
<template> <template>
<VCard title="存储空间" subtitle="" class="position-relative"> <VCard
title="存储空间"
subtitle=""
class="position-relative"
>
<VCardText> <VCardText>
<h5 class="text-2xl font-weight-medium text-primary"> <h5 class="text-2xl font-weight-medium text-primary">
{{ formatFileSize(storage) }} {{ formatFileSize(storage) }}
</h5> </h5>
<p class="mt-2">已使用 {{ usedPercent }}% 🚀</p> <p class="mt-2">
<p class="mt-1"><VProgressLinear :model-value="usedPercent" color="primary" /></p> 已使用 {{ usedPercent }}% 🚀
</p>
<p class="mt-1">
<VProgressLinear
:model-value="usedPercent"
color="primary"
/>
</p>
</VCardText> </VCardText>
<!-- Triangle Background --> <!-- Triangle Background -->
<VImg :src="triangleBg" class="triangle-bg flip-in-rtl" /> <VImg
:src="triangleBg"
class="triangle-bg flip-in-rtl"
/>
<!-- Trophy --> <!-- Trophy -->
<VImg :src="trophy" class="trophy" /> <VImg
:src="trophy"
class="trophy"
/>
</VCard> </VCard>
</template> </template>
+52 -37
View File
@@ -1,23 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import api from "@/api"; import VueApexCharts from 'vue3-apexcharts'
import { hexToRgb } from "@layouts/utils"; import { useTheme } from 'vuetify'
import VueApexCharts from "vue3-apexcharts"; import api from '@/api'
import { useTheme } from "vuetify"; import { hexToRgb } from '@layouts/utils'
const vuetifyTheme = useTheme(); const vuetifyTheme = useTheme()
const options = controlledComputed( const options = controlledComputed(
() => vuetifyTheme.name.value, () => vuetifyTheme.name.value,
() => { () => {
const currentTheme = ref(vuetifyTheme.current.value.colors); const currentTheme = ref(vuetifyTheme.current.value.colors)
const variableTheme = ref(vuetifyTheme.current.value.variables); const variableTheme = ref(vuetifyTheme.current.value.variables)
const disabledColor = `rgba(${hexToRgb(currentTheme.value["on-surface"])},${ const disabledColor = `rgba(${hexToRgb(currentTheme.value['on-surface'])},${
variableTheme.value["disabled-opacity"] variableTheme.value['disabled-opacity']
})`; })`
const borderColor = `rgba(${hexToRgb(String(variableTheme.value["border-color"]))},${
variableTheme.value["border-opacity"] const borderColor = `rgba(${hexToRgb(String(variableTheme.value['border-color']))},${
})`; variableTheme.value['border-opacity']
})`
return { return {
chart: { chart: {
@@ -28,9 +29,9 @@ const options = controlledComputed(
bar: { bar: {
borderRadius: 9, borderRadius: 9,
distributed: true, distributed: true,
columnWidth: "40%", columnWidth: '40%',
endingShape: "rounded", endingShape: 'rounded',
startingShape: "rounded", startingShape: 'rounded',
}, },
}, },
stroke: { stroke: {
@@ -54,12 +55,12 @@ const options = controlledComputed(
dataLabels: { enabled: false }, dataLabels: { enabled: false },
colors: [currentTheme.value.primary], colors: [currentTheme.value.primary],
states: { states: {
hover: { filter: { type: "none" } }, hover: { filter: { type: 'none' } },
active: { filter: { type: "none" } }, active: { filter: { type: 'none' } },
}, },
xaxis: { xaxis: {
categories: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], categories: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
tickPlacement: "on", tickPlacement: 'on',
labels: { show: false }, labels: { show: false },
crosshairs: { opacity: 0 }, crosshairs: { opacity: 0 },
axisTicks: { show: false }, axisTicks: { show: false },
@@ -72,36 +73,38 @@ const options = controlledComputed(
offsetX: -17, offsetX: -17,
style: { style: {
colors: disabledColor, colors: disabledColor,
fontSize: "12px", fontSize: '12px',
}, },
formatter: (value: number) => formatter: (value: number) =>
`${value > 999 ? `${(value / 1000).toFixed(0)}` : value}`, value > 999 ? (value / 1000).toFixed(0) : value,
}, },
}, },
};
} }
); },
)
// 图表数据 // 图表数据
const series = ref([{ data: [0, 0, 0, 0, 0, 0, 0] }]); const series = ref([{ data: [0, 0, 0, 0, 0, 0, 0] }])
// 总数 // 总数
const totalCount = computed(() => series.value[0].data.reduce((a, b) => a + b, 0)); const totalCount = computed(() => series.value[0].data.reduce((a, b) => a + b, 0))
// 调用API接口获取数据近7天数据 // 调用API接口获取数据近7天数据
const getWeeklyData = async () => { async function getWeeklyData() {
try { try {
const res: number[] = await api.get("dashboard/transfer"); const res: number[] = await api.get('dashboard/transfer')
series.value = [{ data: res }];
} catch (e) { series.value = [{ data: res }]
console.log(e); }
catch (e) {
console.log(e)
}
} }
};
onMounted(() => { onMounted(() => {
getWeeklyData(); getWeeklyData()
}); })
</script> </script>
<template> <template>
@@ -111,14 +114,26 @@ onMounted(() => {
</VCardItem> </VCardItem>
<VCardText> <VCardText>
<VueApexCharts type="bar" :options="options" :series="series" :height="160" /> <VueApexCharts
type="bar"
:options="options"
:series="series"
:height="160"
/>
<div class="d-flex align-center mb-3"> <div class="d-flex align-center mb-3">
<h5 class="text-h5 me-4">{{ totalCount }}</h5> <h5 class="text-h5 me-4">
{{ totalCount }}
</h5>
<p>最近一周入库了 {{ totalCount }} 部影片 😎</p> <p>最近一周入库了 {{ totalCount }} 部影片 😎</p>
</div> </div>
<VBtn block to="/history"> 查看详情 </VBtn> <VBtn
block
to="/history"
>
查看详情
</VBtn>
</VCardText> </VCardText>
</VCard> </VCard>
</template> </template>
+76 -57
View File
@@ -1,59 +1,64 @@
<script lang="ts" setup> <script lang="ts" setup>
import api from "@/api"; import api from '@/api'
import { MediaInfo } from "@/api/types"; import type { MediaInfo } from '@/api/types'
import MediaCard from "@/components/cards/MediaCard.vue"; import MediaCard from '@/components/cards/MediaCard.vue'
import NoDataFound from "@/components/NoDataFound.vue"; import NoDataFound from '@/components/NoDataFound.vue'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
apipath: String, apipath: String,
params: Object as PropType<{ [key: string]: any }>, params: Object as PropType<{ [key: string]: any }>,
}); })
// 判断是否有滚动条 // 判断是否有滚动条
const hasScroll = () => { function hasScroll() {
return ( return (
document.body.scrollHeight - document.body.scrollHeight
(window.innerHeight || document.documentElement.clientHeight) > - (window.innerHeight || document.documentElement.clientHeight)
2 > 2
); )
}; }
// 当前页码 // 当前页码
const page = ref(1); const page = ref(1)
// 是否加载中 // 是否加载中
const loading = ref(false); const loading = ref(false)
// 是否加载完成 // 是否加载完成
const isRefreshed = ref(false); const isRefreshed = ref(false)
// 数据列表 // 数据列表
const dataList = ref<MediaInfo[]>([]); const dataList = ref<MediaInfo[]>([])
const currData = ref<MediaInfo[]>([]); const currData = ref<MediaInfo[]>([])
// 拼装参数 // 拼装参数
const getParams = () => { function getParams() {
let params = { let params = {
page: page.value, page: page.value,
};
if (props.params) {
params = { ...params, ...props.params };
} }
return params; if (props.params)
}; params = { ...params, ...props.params }
return params
}
// 获取订阅列表数据 // 获取订阅列表数据
const fetchData = async ({ done }: { done: any }) => { async function fetchData({ done }: { done: any }) {
try { try {
if (!props.apipath) { if (!props.apipath)
return; return
}
// 如果正在加载中,直接返回 // 如果正在加载中,直接返回
if (loading.value) { if (loading.value) {
done("ok"); done('ok')
return;
return
} }
// 设置加载中 // 设置加载中
loading.value = true; loading.value = true
// 加载到满屏或者加载出错 // 加载到满屏或者加载出错
if (!hasScroll()) { if (!hasScroll()) {
// 加载多次 // 加载多次
@@ -61,83 +66,97 @@ const fetchData = async ({ done }: { done: any }) => {
// 请求API // 请求API
currData.value = await api.get(props.apipath, { currData.value = await api.get(props.apipath, {
params: getParams(), params: getParams(),
}); })
// 标计为已请求完成 // 标计为已请求完成
isRefreshed.value = true; isRefreshed.value = true
if (currData.value.length === 0) { if (currData.value.length === 0) {
// 如果没有数据,跳出 // 如果没有数据,跳出
done("ok"); done('ok')
return;
return
} }
// 合并数据 // 合并数据
dataList.value = [...dataList.value, ...currData.value]; dataList.value = [...dataList.value, ...currData.value]
// 页码+1 // 页码+1
page.value++; page.value++
} }
} else { }
else {
// 加载一次 // 加载一次
// 请求API // 请求API
currData.value = await api.get(props.apipath, { currData.value = await api.get(props.apipath, {
params: getParams(), params: getParams(),
}); })
// 标计为已请求完成 // 标计为已请求完成
isRefreshed.value = true; isRefreshed.value = true
if (currData.value.length === 0) { if (currData.value.length === 0) {
// 如果没有数据,跳出 // 如果没有数据,跳出
done("ok"); done('ok')
return;
return
} }
// 合并数据 // 合并数据
dataList.value = [...dataList.value, ...currData.value]; dataList.value = [...dataList.value, ...currData.value]
// 页码+1 // 页码+1
page.value++; page.value++
} }
// 取消加载中 // 取消加载中
loading.value = false; loading.value = false
// 返回加载成功 // 返回加载成功
done("ok"); done('ok')
} catch (error) { }
console.error(error); catch (error) {
console.error(error)
// 返回加载失败 // 返回加载失败
done("error"); done('error')
}
} }
};
</script> </script>
<template> <template>
<VProgressCircular <VProgressCircular
class="centered"
v-if="!isRefreshed" v-if="!isRefreshed"
class="centered"
indeterminate indeterminate
color="primary" color="primary"
></VProgressCircular> />
<VInfiniteScroll <VInfiniteScroll
mode="intersect" mode="intersect"
side="end" side="end"
:onLoad="fetchData" :on-load="fetchData"
class="overflow-hidden" class="overflow-hidden"
> >
<template #loading /> <template #loading />
<div class="grid gap-4 grid-media-card mx-3" v-if="dataList.length > 0" tabindex="0"> <div
v-if="dataList.length > 0"
class="grid gap-4 grid-media-card mx-3"
tabindex="0"
>
<MediaCard <MediaCard
v-for="data in dataList" v-for="data in dataList"
:key="data.tmdb_id || data.douban_id" :key="data.tmdb_id || data.douban_id"
:media="data" :media="data"
> />
</MediaCard>
</div> </div>
<NoDataFound <NoDataFound
v-if="dataList.length === 0 && isRefreshed" v-if="dataList.length === 0 && isRefreshed"
error-code="500" error-code="500"
error-title="出错啦" error-title="出错啦"
error-description="无法获取到媒体信息请检查网络连接" error-description="无法获取到媒体信息请检查网络连接"
> />
</NoDataFound>
</VInfiniteScroll> </VInfiniteScroll>
</template> </template>
<style type="scss"> <style lang="scss">
.grid-media-card { .grid-media-card {
grid-template-columns: repeat(auto-fill, minmax(9.375rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(9.375rem, 1fr));
} }
+36 -21
View File
@@ -1,57 +1,72 @@
<script lang="ts" setup> <script lang="ts" setup>
import api from "@/api"; import api from '@/api'
import { MediaInfo } from "@/api/types"; import type { MediaInfo } from '@/api/types'
import MediaCard from "@/components/cards/MediaCard.vue"; import MediaCard from '@/components/cards/MediaCard.vue'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
apipath: String, apipath: String,
}); })
// 组件加载完成 // 组件加载完成
const componentLoaded = ref(false); const componentLoaded = ref(false)
// 数据列表 // 数据列表
const dataList = ref<MediaInfo[]>([]); const dataList = ref<MediaInfo[]>([])
// 获取订阅列表数据 // 获取订阅列表数据
const fetchData = async () => { async function fetchData() {
try { try {
if (!props.apipath) { if (!props.apipath)
return; return
dataList.value = await api.get(props.apipath)
componentLoaded.value = true
}
catch (error) {
console.error(error)
} }
dataList.value = await api.get(props.apipath);
componentLoaded.value = true;
} catch (error) {
console.error(error);
} }
};
// 加载时获取数据 // 加载时获取数据
onMounted(fetchData); onMounted(fetchData)
</script> </script>
<template> <template>
<slot name="title" :loaded="componentLoaded"></slot> <slot
name="title"
:loaded="componentLoaded"
/>
<VSlideGroup show-arrows="false"> <VSlideGroup show-arrows="false">
<template #prev> <template #prev>
<VBtn class="rounded-circle shadow-none" icon="mdi-chevron-left" color="grey" /> <VBtn
class="rounded-circle shadow-none"
icon="mdi-chevron-left"
color="grey"
/>
</template> </template>
<VSlideGroupItem v-for="data in dataList" :key="data.tmdb_id"> <VSlideGroupItem
v-for="data in dataList"
:key="data.tmdb_id"
>
<MediaCard <MediaCard
:key="data.tmdb_id || data.douban_id"
:media="data" :media="data"
height="15rem" height="15rem"
width="10rem" width="10rem"
:key="data.tmdb_id || data.douban_id"
/> />
</VSlideGroupItem> </VSlideGroupItem>
<template #next> <template #next>
<VBtn class="rounded-circle shadow-none" icon="mdi-chevron-right" color="grey" /> <VBtn
class="rounded-circle shadow-none"
icon="mdi-chevron-right"
color="grey"
/>
</template> </template>
</VSlideGroup> </VSlideGroup>
</template> </template>
<style type="scss"> <style lang="scss">
.v-slide-group .v-card { .v-slide-group .v-card {
@apply m-2; @apply m-2;
} }
+195 -157
View File
@@ -1,279 +1,295 @@
<script lang="ts" setup> <script lang="ts" setup>
import { isIntersected } from "@/@core/utils"; import { isIntersected } from '@/@core/utils'
import api from "@/api"; import api from '@/api'
import { Context } from "@/api/types"; import type { Context } from '@/api/types'
import TorrentCard from "@/components/cards/TorrentCard.vue"; import TorrentCard from '@/components/cards/TorrentCard.vue'
import NoDataFound from "@/components/NoDataFound.vue"; import NoDataFound from '@/components/NoDataFound.vue'
import store from "@/store"; import store from '@/store'
// 定义输入参数 // 定义输入参数
const props = defineProps({ const props = defineProps({
// 关键字或TMDBID // 关键字或TMDBID
keyword: String, keyword: String,
// 类型 // 类型
type: String, type: String,
}); })
// 数据列表 // 数据列表
const dataList = ref<Context[]>([]); const dataList = ref<Context[]>([])
// 分组后的数据列表 // 分组后的数据列表
const groupedDataList = computed(() => { const groupedDataList = computed(() => {
return groupByTitleAndSize(dataList.value); return groupByTitleAndSize(dataList.value)
}); })
// 是否刷新过 // 是否刷新过
const isRefreshed = ref(false); const isRefreshed = ref(false)
// 加载进度文本 // 加载进度文本
const progressText = ref(""); const progressText = ref('')
// 加载进度 // 加载进度
const progressValue = ref(0); const progressValue = ref(0)
// 加载进度SSE // 加载进度SSE
const progressEventSource = ref<EventSource>(); const progressEventSource = ref<EventSource>()
// 过滤表单 // 过滤表单
const filterForm = reactive({ const filterForm = reactive({
// 站点 // 站点
site: [] as string[], site: [] as string[],
// 季 // 季
season: [] as string[], season: [] as string[],
// 制作组 // 制作组
releaseGroup: [] as string[], releaseGroup: [] as string[],
// 视频编码 // 视频编码
videoCode: [] as string[], videoCode: [] as string[],
// 促销状态 // 促销状态
freeState: [] as string[], freeState: [] as string[],
// 质量 // 质量
edition: [] as string[], edition: [] as string[],
}); })
// 获取站点过滤选项 // 获取站点过滤选项
const getSiteFilterOptions = computed(() => { const getSiteFilterOptions = computed(() => {
const options: string[] = []; const options: string[] = []
dataList.value.forEach((data) => { dataList.value.forEach((data) => {
if (data.torrent_info?.site_name && !options.includes(data.torrent_info?.site_name)) { if (data.torrent_info?.site_name && !options.includes(data.torrent_info?.site_name))
options.push(data.torrent_info?.site_name); options.push(data.torrent_info?.site_name)
} })
});
return options; return options
}); })
// 获取季过滤选项 // 获取季过滤选项
const getSeasonFilterOptions = computed(() => { const getSeasonFilterOptions = computed(() => {
const options: string[] = []; const options: string[] = []
dataList.value.forEach((data) => { dataList.value.forEach((data) => {
if ( if (
data.meta_info.season_episode && data.meta_info.season_episode
!options.includes(data.meta_info.season_episode) && !options.includes(data.meta_info.season_episode)
) { )
options.push(data.meta_info.season_episode); options.push(data.meta_info.season_episode)
} })
});
return options; return options
}); })
// 获取制作组过滤选项 // 获取制作组过滤选项
const getReleaseGroupFilterOptions = computed(() => { const getReleaseGroupFilterOptions = computed(() => {
const options: string[] = []; const options: string[] = []
dataList.value.forEach((data) => { dataList.value.forEach((data) => {
if (data.meta_info.resource_team && !options.includes(data.meta_info.resource_team)) { if (data.meta_info.resource_team && !options.includes(data.meta_info.resource_team))
options.push(data.meta_info.resource_team); options.push(data.meta_info.resource_team)
} })
});
return options; return options
}); })
// 获取视频编码过滤选项 // 获取视频编码过滤选项
const getVideoCodeFilterOptions = computed(() => { const getVideoCodeFilterOptions = computed(() => {
const options: string[] = []; const options: string[] = []
dataList.value.forEach((data) => { dataList.value.forEach((data) => {
if (data.meta_info.video_encode && !options.includes(data.meta_info.video_encode)) { if (data.meta_info.video_encode && !options.includes(data.meta_info.video_encode))
options.push(data.meta_info.video_encode); options.push(data.meta_info.video_encode)
} })
});
return options; return options
}); })
// 获取促销状态过滤选项 // 获取促销状态过滤选项
const getFreeStateFilterOptions = computed(() => { const getFreeStateFilterOptions = computed(() => {
const options: string[] = []; const options: string[] = []
dataList.value.forEach((data) => { dataList.value.forEach((data) => {
if ( if (
data.torrent_info.volume_factor && data.torrent_info.volume_factor
!options.includes(data.torrent_info.volume_factor) && !options.includes(data.torrent_info.volume_factor)
) { )
options.push(data.torrent_info.volume_factor); options.push(data.torrent_info.volume_factor)
} })
});
return options; return options
}); })
// 获取质量过滤选项 // 获取质量过滤选项
const getEditionFilterOptions = computed(() => { const getEditionFilterOptions = computed(() => {
const options: string[] = []; const options: string[] = []
dataList.value.forEach((data) => { dataList.value.forEach((data) => {
if (data.meta_info.edition && !options.includes(data.meta_info.edition)) { if (data.meta_info.edition && !options.includes(data.meta_info.edition))
options.push(data.meta_info.edition); options.push(data.meta_info.edition)
} })
});
return options; return options
}); })
// 按过滤项过滤卡片 // 按过滤项过滤卡片
const filterTorrentsCard = (data: Context) => { function filterTorrentsCard(data: Context) {
// 当前分组的所有数据 // 当前分组的所有数据
const items: Context[] = const items: Context[]
groupedDataList.value.get(`${data.torrent_info.title}_${data.torrent_info.size}`) ?? = groupedDataList.value.get(`${data.torrent_info.title}_${data.torrent_info.size}`)
[]; ?? []
// 站点名称、促销状态 // 站点名称、促销状态
let site_names = []; const site_names = []
let volume_factors = []; const volume_factors = []
for (const { torrent_info } of items) { for (const { torrent_info } of items) {
site_names.push(torrent_info.site_name); site_names.push(torrent_info.site_name)
volume_factors.push(torrent_info.volume_factor); volume_factors.push(torrent_info.volume_factor)
} }
const { meta_info } = data; const { meta_info } = data
// 季、制作组、视频编码 // 季、制作组、视频编码
const { season_episode, resource_team, video_encode } = meta_info; const { season_episode, resource_team, video_encode } = meta_info
// 站点过滤 // 站点过滤
if (filterForm.site.length > 0 && !isIntersected(filterForm.site, site_names)) { if (filterForm.site.length > 0 && !isIntersected(filterForm.site, site_names))
return false; return false
}
// 促销状态过滤 // 促销状态过滤
if ( if (
filterForm.freeState.length > 0 && filterForm.freeState.length > 0
!isIntersected(filterForm.freeState, volume_factors) && !isIntersected(filterForm.freeState, volume_factors)
) { )
return false; return false
}
// 季过滤 // 季过滤
if (filterForm.season.length > 0 && !filterForm.season.includes(season_episode)) { if (filterForm.season.length > 0 && !filterForm.season.includes(season_episode))
return false; return false
}
// 制作组过滤 // 制作组过滤
if ( if (
filterForm.releaseGroup.length > 0 && filterForm.releaseGroup.length > 0
!filterForm.releaseGroup.includes(resource_team || "") && !filterForm.releaseGroup.includes(resource_team || '')
) { )
return false; return false
}
// 视频编码过滤 // 视频编码过滤
if ( if (
filterForm.videoCode.length > 0 && filterForm.videoCode.length > 0
!filterForm.videoCode.includes(video_encode || "") && !filterForm.videoCode.includes(video_encode || '')
) { )
return false; return false
}
// 质量过滤 // 质量过滤
if (filterForm.edition.length > 0 && !filterForm.edition.includes(meta_info.edition)) { return !(filterForm.edition.length > 0 && !filterForm.edition.includes(meta_info.edition))
return false;
} }
return true;
};
// 获取订阅列表数据 // 获取订阅列表数据
const fetchData = async () => { async function fetchData() {
try { try {
let keyword = props.keyword ?? ""; const keyword = props.keyword ?? ''
let mtype = props.type ?? ""; const mtype = props.type ?? ''
if (!keyword) { if (!keyword) {
// 查询上次搜索结果 // 查询上次搜索结果
dataList.value = await api.get("search/last"); dataList.value = await api.get('search/last')
} else { }
startLoadingProgress(); else {
startLoadingProgress()
// 优先按TMDBID精确查询 // 优先按TMDBID精确查询
if (props.keyword?.startsWith("tmdb:") || props.keyword?.startsWith("douban:")) { if (props.keyword?.startsWith('tmdb:') || props.keyword?.startsWith('douban:')) {
dataList.value = await api.get(`search/media/${props.keyword}`, { dataList.value = await api.get(`search/media/${props.keyword}`, {
params: { params: {
mtype, mtype,
}, },
}); })
} else { }
else {
// 按标题模糊查询 // 按标题模糊查询
dataList.value = await api.get(`search/title/${props.keyword}`); dataList.value = await api.get(`search/title/${props.keyword}`)
} }
stopLoadingProgress(); stopLoadingProgress()
}
isRefreshed.value = true
}
catch (error) {
console.error(error)
} }
isRefreshed.value = true;
} catch (error) {
console.error(error);
} }
};
// 按标题和大小分组 // 按标题和大小分组
const groupByTitleAndSize = (contextArray: Context[]): Map<string, Context[]> => { function groupByTitleAndSize(contextArray: Context[]): Map<string, Context[]> {
const groupMap = new Map<string, Context[]>(); const groupMap = new Map<string, Context[]>()
for (const context of contextArray) { for (const context of contextArray) {
const { torrent_info } = context; const { torrent_info } = context
const key = `${torrent_info.title}_${torrent_info.size}`; const key = `${torrent_info.title}_${torrent_info.size}`
if (groupMap.has(key)) { if (groupMap.has(key)) {
// 已存在相同标题和大小的分组,将当前上下文信息添加到分组中 // 已存在相同标题和大小的分组,将当前上下文信息添加到分组中
const group = groupMap.get(key); const group = groupMap.get(key)
group?.push(context);
} else { group?.push(context)
}
else {
// 创建新的分组,并将当前上下文信息添加到分组中 // 创建新的分组,并将当前上下文信息添加到分组中
groupMap.set(key, [context]); groupMap.set(key, [context])
} }
} }
return groupMap; return groupMap
}; }
// 获取每个分组的第一个数据 // 获取每个分组的第一个数据
const getFirstContexts = computed(() => { const getFirstContexts = computed(() => {
const firstContexts: Context[] = []; const firstContexts: Context[] = []
groupedDataList.value.forEach((group) => { groupedDataList.value.forEach((group) => {
if (group.length > 0) { if (group.length > 0)
firstContexts.push(group[0]); firstContexts.push(group[0])
} })
});
return firstContexts; return firstContexts
}); })
// 使用SSE监听加载进度 // 使用SSE监听加载进度
const startLoadingProgress = () => { function startLoadingProgress() {
progressText.value = "正在搜索,请稍候..."; progressText.value = '正在搜索,请稍候...'
const token = store.state.auth.token;
const token = store.state.auth.token
progressEventSource.value = new EventSource( progressEventSource.value = new EventSource(
`${import.meta.env.VITE_API_BASE_URL}system/progress/search?token=${token}` `${import.meta.env.VITE_API_BASE_URL}system/progress/search?token=${token}`,
); )
progressEventSource.value.onmessage = (event) => { progressEventSource.value.onmessage = (event) => {
const progress = JSON.parse(event.data); const progress = JSON.parse(event.data)
if (progress) { if (progress) {
progressText.value = progress.text; progressText.value = progress.text
progressValue.value = progress.value; progressValue.value = progress.value
}
}
} }
};
};
// 停止监听加载进度 // 停止监听加载进度
const stopLoadingProgress = () => { function stopLoadingProgress() {
progressEventSource.value?.close(); progressEventSource.value?.close()
}; }
// 加载时获取数据 // 加载时获取数据
onBeforeMount(fetchData); onBeforeMount(fetchData)
</script> </script>
<template> <template>
<VCard class="bg-transparent mb-3 pt-2 shadow-none"> <VCard class="bg-transparent mb-3 pt-2 shadow-none">
<VRow> <VRow>
<VCol v-if="getSiteFilterOptions.length > 0" cols="6" md=""> <VCol
v-if="getSiteFilterOptions.length > 0"
cols="6"
md=""
>
<VSelect <VSelect
v-model="filterForm.site" v-model="filterForm.site"
:items="getSiteFilterOptions" :items="getSiteFilterOptions"
@@ -284,7 +300,11 @@ onBeforeMount(fetchData);
multiple multiple
/> />
</VCol> </VCol>
<VCol v-if="getSeasonFilterOptions.length > 0" cols="6" md=""> <VCol
v-if="getSeasonFilterOptions.length > 0"
cols="6"
md=""
>
<VSelect <VSelect
v-model="filterForm.season" v-model="filterForm.season"
:items="getSeasonFilterOptions" :items="getSeasonFilterOptions"
@@ -295,7 +315,11 @@ onBeforeMount(fetchData);
multiple multiple
/> />
</VCol> </VCol>
<VCol v-if="getReleaseGroupFilterOptions.length > 0" cols="6" md=""> <VCol
v-if="getReleaseGroupFilterOptions.length > 0"
cols="6"
md=""
>
<VSelect <VSelect
v-model="filterForm.releaseGroup" v-model="filterForm.releaseGroup"
:items="getReleaseGroupFilterOptions" :items="getReleaseGroupFilterOptions"
@@ -306,7 +330,11 @@ onBeforeMount(fetchData);
multiple multiple
/> />
</VCol> </VCol>
<VCol v-if="getEditionFilterOptions.length > 0" cols="6" md=""> <VCol
v-if="getEditionFilterOptions.length > 0"
cols="6"
md=""
>
<VSelect <VSelect
v-model="filterForm.edition" v-model="filterForm.edition"
:items="getEditionFilterOptions" :items="getEditionFilterOptions"
@@ -317,7 +345,11 @@ onBeforeMount(fetchData);
multiple multiple
/> />
</VCol> </VCol>
<VCol v-if="getVideoCodeFilterOptions.length > 0" cols="6" md=""> <VCol
v-if="getVideoCodeFilterOptions.length > 0"
cols="6"
md=""
>
<VSelect <VSelect
v-model="filterForm.videoCode" v-model="filterForm.videoCode"
:items="getVideoCodeFilterOptions" :items="getVideoCodeFilterOptions"
@@ -328,7 +360,11 @@ onBeforeMount(fetchData);
multiple multiple
/> />
</VCol> </VCol>
<VCol v-if="getFreeStateFilterOptions.length > 0" cols="6" md=""> <VCol
v-if="getFreeStateFilterOptions.length > 0"
cols="6"
md=""
>
<VSelect <VSelect
v-model="filterForm.freeState" v-model="filterForm.freeState"
:items="getFreeStateFilterOptions" :items="getFreeStateFilterOptions"
@@ -342,14 +378,14 @@ onBeforeMount(fetchData);
</VRow> </VRow>
</VCard> </VCard>
<VProgressCircular <VProgressCircular
class="centered"
v-if="!isRefreshed && !props.keyword" v-if="!isRefreshed && !props.keyword"
class="centered"
indeterminate indeterminate
color="primary" color="primary"
></VProgressCircular> />
<div <div
class="top-centered mt-12 w-full text-center text-gray-500 text-sm flex flex-col items-center"
v-if="!isRefreshed && props.keyword" v-if="!isRefreshed && props.keyword"
class="top-centered mt-12 w-full text-center text-gray-500 text-sm flex flex-col items-center"
> >
<VProgressCircular <VProgressCircular
class="mb-3" class="mb-3"
@@ -357,10 +393,13 @@ onBeforeMount(fetchData);
:model-value="progressValue" :model-value="progressValue"
size="64" size="64"
width="7" width="7"
></VProgressCircular> />
<span>{{ progressText }}</span> <span>{{ progressText }}</span>
</div> </div>
<div class="grid gap-3 grid-torrent-card items-start" v-if="dataList.length > 0"> <div
v-if="dataList.length > 0"
class="grid gap-3 grid-torrent-card items-start"
>
<TorrentCard <TorrentCard
v-for="data in getFirstContexts" v-for="data in getFirstContexts"
v-show="filterTorrentsCard(data)" v-show="filterTorrentsCard(data)"
@@ -378,11 +417,10 @@ onBeforeMount(fetchData);
error-code="404" error-code="404"
error-title="没有资源" error-title="没有资源"
error-description="没有搜索到符合条件的资源" error-description="没有搜索到符合条件的资源"
> />
</NoDataFound>
</template> </template>
<style type="scss"> <style lang="scss">
.grid-torrent-card { .grid-torrent-card {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
padding-block-end: 1rem; padding-block-end: 1rem;
@@ -1,62 +1,66 @@
<script lang="ts" setup> <script lang="ts" setup>
import api from "@/api"; import api from '@/api'
import { Plugin } from "@/api/types"; import type { Plugin } from '@/api/types'
import NoDataFound from "@/components/NoDataFound.vue"; import NoDataFound from '@/components/NoDataFound.vue'
import PluginAppCard from "@/components/cards/PluginAppCard.vue"; import PluginAppCard from '@/components/cards/PluginAppCard.vue'
import PluginCard from "@/components/cards/PluginCard.vue"; import PluginCard from '@/components/cards/PluginCard.vue'
// //
const dataList = ref<Plugin[]>([]); const dataList = ref<Plugin[]>([])
// //
const isRefreshed = ref(false); const isRefreshed = ref(false)
// APP // APP
const PluginAppDialog = ref(false); const PluginAppDialog = ref(false)
// //
const getInstalledPluginList = computed(() => { const getInstalledPluginList = computed(() => {
return dataList.value.filter((item) => item.installed); return dataList.value.filter(item => item.installed)
}); })
// //
const getUninstalledPluginList = computed(() => { const getUninstalledPluginList = computed(() => {
return dataList.value.filter((item) => !item.installed); return dataList.value.filter(item => !item.installed)
}); })
// //
const pluginDialogClose = () => { function pluginDialogClose() {
PluginAppDialog.value = false; PluginAppDialog.value = false
}; }
// //
const pluginInstalled = () => { function pluginInstalled() {
fetchData(); fetchData()
pluginDialogClose(); pluginDialogClose()
}; }
// //
const fetchData = async () => { async function fetchData() {
try { try {
dataList.value = await api.get("plugin"); dataList.value = await api.get('plugin')
isRefreshed.value = true; isRefreshed.value = true
} catch (error) { }
console.error(error); catch (error) {
console.error(error)
}
} }
};
// //
onBeforeMount(fetchData); onBeforeMount(fetchData)
</script> </script>
<template> <template>
<VProgressCircular <VProgressCircular
class="centered"
v-if="!isRefreshed" v-if="!isRefreshed"
class="centered"
indeterminate indeterminate
color="primary" color="primary"
></VProgressCircular> />
<div class="grid gap-3 grid-plugin-card" v-if="dataList.length > 0"> <div
v-if="dataList.length > 0"
class="grid gap-3 grid-plugin-card"
>
<PluginCard <PluginCard
v-for="data in getInstalledPluginList" v-for="data in getInstalledPluginList"
:key="data.id" :key="data.id"
@@ -69,8 +73,7 @@ onBeforeMount(fetchData);
error-code="404" error-code="404"
error-title="没有安装插件" error-title="没有安装插件"
error-description="点击右下角按钮,前往插件市场安装插件。" error-description="点击右下角按钮,前往插件市场安装插件。"
> />
</NoDataFound>
<!-- App市场 --> <!-- App市场 -->
<VDialog <VDialog
v-model="PluginAppDialog" v-model="PluginAppDialog"
@@ -80,8 +83,12 @@ onBeforeMount(fetchData);
> >
<!-- Dialog Activator --> <!-- Dialog Activator -->
<template #activator="{ props }"> <template #activator="{ props }">
<VBtn icon="mdi-plus" v-bind="props" size="x-large" class="fixed right-5 bottom-5"> <VBtn
</VBtn> icon="mdi-plus"
v-bind="props"
size="x-large"
class="fixed right-5 bottom-5"
/>
</template> </template>
<!-- Dialog Content --> <!-- Dialog Content -->
@@ -94,8 +101,14 @@ onBeforeMount(fetchData);
<VSpacer /> <VSpacer />
<VToolbarItems> <VToolbarItems>
<VBtn size="x-large" @click="pluginDialogClose"> <VBtn
<VIcon color="white" icon="mdi-close" /> size="x-large"
@click="pluginDialogClose"
>
<VIcon
color="white"
icon="mdi-close"
/>
</VBtn> </VBtn>
</VToolbarItems> </VToolbarItems>
</VToolbar> </VToolbar>
@@ -114,14 +127,13 @@ onBeforeMount(fetchData);
error-code="404" error-code="404"
error-title="没有未安装插件" error-title="没有未安装插件"
error-description="所有可用插件均已安装。" error-description="所有可用插件均已安装。"
> />
</NoDataFound>
</div> </div>
</VCard> </VCard>
</VDialog> </VDialog>
</template> </template>
<style type="scss"> <style lang="scss">
.grid-plugin-card { .grid-plugin-card {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
padding-block-end: 1rem; padding-block-end: 1rem;
+52 -42
View File
@@ -1,82 +1,92 @@
<script lang="ts" setup> <script lang="ts" setup>
import api from "@/api"; import PullRefresh from 'pull-refresh-vue3'
import type { DownloadingInfo } from "@/api/types"; import api from '@/api'
import NoDataFound from "@/components/NoDataFound.vue"; import type { DownloadingInfo } from '@/api/types'
import DownloadingCard from "@/components/cards/DownloadingCard.vue"; import NoDataFound from '@/components/NoDataFound.vue'
import PullRefresh from "pull-refresh-vue3"; import DownloadingCard from '@/components/cards/DownloadingCard.vue'
// 定时器 // 定时器
let refreshTimer: NodeJS.Timer | null = null; let refreshTimer: NodeJS.Timer | null = null
// 数据列表 // 数据列表
const dataList = ref<DownloadingInfo[]>([]); const dataList = ref<DownloadingInfo[]>([])
// 获取订阅列表数据
const fetchData = async () => {
try {
dataList.value = await api.get("download");
isRefreshed.value = true;
} catch (error) {
console.error(error);
}
};
// 刷新状态
const loading = ref(false);
// 是否刷新过 // 是否刷新过
const isRefreshed = ref(false); const isRefreshed = ref(false)
// 获取订阅列表数据
async function fetchData() {
try {
dataList.value = await api.get('download')
isRefreshed.value = true
}
catch (error) {
console.error(error)
}
}
// 刷新状态
const loading = ref(false)
// 下拉刷新 // 下拉刷新
const onRefresh = () => { function onRefresh() {
loading.value = true; loading.value = true
fetchData(); fetchData()
loading.value = false; loading.value = false
}; }
// 加载时获取数据 // 加载时获取数据
onBeforeMount(() => { onBeforeMount(() => {
fetchData(); fetchData()
// 启动定时器 // 启动定时器
refreshTimer = setInterval(() => { refreshTimer = setInterval(() => {
fetchData(); fetchData()
}, 3000); }, 3000)
}); })
// 组件卸载时停止定时器 // 组件卸载时停止定时器
onUnmounted(() => { onUnmounted(() => {
if (refreshTimer) { if (refreshTimer) {
clearInterval(refreshTimer); clearInterval(refreshTimer)
refreshTimer = null; refreshTimer = null
} }
}); })
</script> </script>
<template> <template>
<VProgressCircular <VProgressCircular
class="centered"
v-if="!isRefreshed" v-if="!isRefreshed"
class="centered"
indeterminate indeterminate
color="primary" color="primary"
></VProgressCircular> />
<PullRefresh v-model="loading" @refresh="onRefresh"> <PullRefresh
<div class="grid gap-3 grid-downloading-card" v-if="dataList.length > 0"> v-model="loading"
<DownloadingCard v-for="data in dataList" :key="data.hash" :info="data" /> @refresh="onRefresh"
>
<div
v-if="dataList.length > 0"
class="grid gap-3 grid-downloading-card"
>
<DownloadingCard
v-for="data in dataList"
:key="data.hash"
:info="data"
/>
</div> </div>
<NoDataFound <NoDataFound
v-if="dataList.length === 0 && isRefreshed" v-if="dataList.length === 0 && isRefreshed"
error-code="404" error-code="404"
error-title="没有任务" error-title="没有任务"
error-description="正在下载的任务将会显示在这里" error-description="正在下载的任务将会显示在这里"
> />
</NoDataFound>
</PullRefresh> </PullRefresh>
</template> </template>
<style type="scss"> <style lang="scss">
.grid-downloading-card { .grid-downloading-card {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
padding-block-end: 1rem; padding-block-end: 1rem;
} }
</style> </style>
+143 -110
View File
@@ -1,198 +1,209 @@
<script setup lang="ts"> <script setup lang="ts">
import { numberValidator, requiredValidator } from "@/@validators"; import { ref } from 'vue'
import api from "@/api"; import { useToast } from 'vue-toast-notification'
import type { TransferHistory } from "@/api/types"; import { useConfirm } from 'vuetify-use-dialog'
import { ref } from "vue"; import { numberValidator, requiredValidator } from '@/@validators'
import { useToast } from "vue-toast-notification"; import api from '@/api'
import { useConfirm } from "vuetify-use-dialog"; import type { TransferHistory } from '@/api/types'
// 确认框 // 确认框
const createConfirm = useConfirm(); const createConfirm = useConfirm()
// 提示框 // 提示框
const $toast = useToast(); const $toast = useToast()
// 重新整理对话框 // 重新整理对话框
const redoDialog = ref(false); const redoDialog = ref(false)
// TMDB编号 // TMDB编号
const redoTmdbId = ref(""); const redoTmdbId = ref('')
// 当前操作记录 // 当前操作记录
const currentHistory = ref<TransferHistory>(); const currentHistory = ref<TransferHistory>()
// 表头 // 表头
const headers = [ const headers = [
{ title: "标题", key: "title", sortable: false }, { title: '标题', key: 'title', sortable: false },
{ title: "目录", key: "src", sortable: false }, { title: '目录', key: 'src', sortable: false },
{ title: "转移方式", key: "mode", sortable: false }, { title: '转移方式', key: 'mode', sortable: false },
{ title: "时间", key: "date", sortable: false }, { title: '时间', key: 'date', sortable: false },
{ title: "状态", key: "status", sortable: false }, { title: '状态', key: 'status', sortable: false },
{ title: "失败原因", key: "errmsg", sortable: false }, { title: '失败原因', key: 'errmsg', sortable: false },
{ title: "", key: "actions", sortable: false }, { title: '', key: 'actions', sortable: false },
]; ]
// 数据列表 // 数据列表
const dataList = ref<TransferHistory[]>([]); const dataList = ref<TransferHistory[]>([])
// 搜索 // 搜索
const search = ref(""); const search = ref('')
// 加载状态 // 加载状态
const loading = ref(false); const loading = ref(false)
// 总条数 // 总条数
const totalItems = ref(0); const totalItems = ref(0)
// 每页条数 // 每页条数
const itemsPerPage = ref(25); const itemsPerPage = ref(25)
// 当前页码 // 当前页码
const currentPage = ref(1); const currentPage = ref(1)
// 获取订阅列表数据 // 获取订阅列表数据
const fetchData = async ({ async function fetchData({
page, page,
itemsPerPage, itemsPerPage,
}: { }: {
page: number; page: number
itemsPerPage: number; itemsPerPage: number
}) => { }) {
loading.value = true; loading.value = true
try { try {
currentPage.value = page; currentPage.value = page
const result: { [key: string]: any } = await api.get("history/transfer", {
const result: { [key: string]: any } = await api.get('history/transfer', {
params: { params: {
page, page,
count: itemsPerPage, count: itemsPerPage,
title: search.value, title: search.value,
}, },
}); })
dataList.value = result.data.list;
totalItems.value = result.data.total; dataList.value = result.data.list
} catch (error) { totalItems.value = result.data.total
console.error(error); }
catch (error) {
console.error(error)
}
loading.value = false
} }
loading.value = false;
};
// 根据 type 返回不同的图标 // 根据 type 返回不同的图标
const getIcon = (type: string) => { function getIcon(type: string) {
if (type === "电影") { if (type === '电影')
return "mdi-movie"; return 'mdi-movie'
} else if (type === "电视剧") { else if (type === '电视剧')
return "mdi-television-classic"; return 'mdi-television-classic'
} else { else
return "mdi-help-circle"; return 'mdi-help-circle'
} }
};
// 计算颜色 // 计算颜色
const getStatusColor = (status: boolean) => { function getStatusColor(status: boolean) {
return status ? "success" : "error"; return status ? 'success' : 'error'
}; }
// 转移方式字典 // 转移方式字典
const TransferDict: { [key: string]: string } = { const TransferDict: { [key: string]: string } = {
copy: "复制", copy: '复制',
move: "移动", move: '移动',
link: "硬链接", link: '硬链接',
softlink: "软链接", softlink: '软链接',
}; }
// 删除历史记录 // 删除历史记录
const removeHistory = async (item: TransferHistory) => { async function removeHistory(item: TransferHistory) {
try { try {
const isConfirmed = await createConfirm({ const isConfirmed = await createConfirm({
title: "确认", title: '确认',
content: `同步删除 ${item.title} 对应的媒体库文件 ?`, content: `同步删除 ${item.title} 对应的媒体库文件 ?`,
confirmationText: "同步删除文件", confirmationText: '同步删除文件',
cancellationText: "仅删除历史记录", cancellationText: '仅删除历史记录',
dialogProps: { dialogProps: {
maxWidth: 600, maxWidth: 600,
}, },
}); })
let deleteFile = false;
if (isConfirmed) { let deleteFile = false
deleteFile = true; if (isConfirmed)
} deleteFile = true
// 调用删除API // 调用删除API
const result: { [key: string]: any } = await api.delete("history/transfer", { const result: { [key: string]: any } = await api.delete('history/transfer', {
data: { data: {
...item, ...item,
delete_file: deleteFile, delete_file: deleteFile,
}, },
}); })
if (result.success) { if (result.success) {
fetchData({ fetchData({
page: currentPage.value, page: currentPage.value,
itemsPerPage: itemsPerPage.value, itemsPerPage: itemsPerPage.value,
}); })
} else { }
$toast.error(`删除失败: ${result.msg}`); else {
$toast.error(`删除失败: ${result.msg}`)
}
}
catch (error) {
console.error(error)
} }
} catch (error) {
console.error(error);
} }
};
// 重新整理 // 重新整理
const rehandleHistory = async () => { async function rehandleHistory() {
try { try {
if (!redoTmdbId.value) { if (!redoTmdbId.value)
return; return
}
redoDialog.value = false; redoDialog.value = false
$toast.info(`正在重新整理 ${currentHistory.value?.title} ...`); $toast.info(`正在重新整理 ${currentHistory.value?.title} ...`)
// 调用API接口重新转移 // 调用API接口重新转移
const requestData = { const requestData = {
...currentHistory.value, ...currentHistory.value,
}; }
const result: { [key: string]: any } = await api.post( const result: { [key: string]: any } = await api.post(
"history/transfer", 'history/transfer',
requestData, requestData,
{ {
params: { params: {
new_tmdbid: parseInt(redoTmdbId.value), new_tmdbid: parseInt(redoTmdbId.value),
}, },
} },
); )
if (result.success) { if (result.success) {
fetchData({ fetchData({
page: currentPage.value, page: currentPage.value,
itemsPerPage: itemsPerPage.value, itemsPerPage: itemsPerPage.value,
}); })
} else { }
$toast.error(`重新整理失败: ${result.message}`); else {
$toast.error(`重新整理失败: ${result.message}`)
}
}
catch (e) {
console.log(e)
} }
} catch (e) {
console.log(e);
} }
};
// 弹出菜单 // 弹出菜单
const dropdownItems = ref([ const dropdownItems = ref([
{ {
title: "重新整理", title: '重新整理',
value: 1, value: 1,
props: { props: {
prependIcon: "mdi-redo-variant", prependIcon: 'mdi-redo-variant',
click: (item: TransferHistory) => { click: (item: TransferHistory) => {
redoDialog.value = true; redoDialog.value = true
currentHistory.value = item; currentHistory.value = item
}, },
}, },
}, },
{ {
title: "删除", title: '删除',
value: 2, value: 2,
props: { props: {
prependIcon: "mdi-trash-can-outline", prependIcon: 'mdi-trash-can-outline',
color: "error", color: 'error',
click: removeHistory, click: removeHistory,
}, },
}, },
]); ])
</script> </script>
<template> <template>
@@ -226,17 +237,17 @@ const dropdownItems = ref([
:items-length="totalItems" :items-length="totalItems"
:search="search" :search="search"
:loading="loading" :loading="loading"
@update:options="fetchData"
density="compact" density="compact"
item-value="id" item-value="id"
return-object return-object
fixed-header fixed-header
items-per-page-text="每页条数" items-per-page-text="每页条数"
page-text="{0}-{1} {2} " page-text="{0}-{1} {2} "
@update:options="fetchData"
> >
<template #item.title="{ item }"> <template #item.title="{ item }">
<div class="d-flex"> <div class="d-flex">
<VAvatar><VIcon :icon="getIcon(item.raw.type || '')"></VIcon></VAvatar> <VAvatar><VIcon :icon="getIcon(item.raw.type || '')" /></VAvatar>
<div class="d-flex flex-column ms-1"> <div class="d-flex flex-column ms-1">
<span class="d-block whitespace-nowrap text-high-emphasis"> <span class="d-block whitespace-nowrap text-high-emphasis">
{{ item.raw.title }} {{ item.raw.seasons }}{{ item.raw.episodes }} {{ item.raw.title }} {{ item.raw.seasons }}{{ item.raw.episodes }}
@@ -246,15 +257,24 @@ const dropdownItems = ref([
</div> </div>
</template> </template>
<template #item.src="{ item }"> <template #item.src="{ item }">
<small>{{ item.raw.src }} <br />=> {{ item.raw.dest }}</small> <small>{{ item.raw.src }} <br>=> {{ item.raw.dest }}</small>
</template> </template>
<template #item.mode="{ item }"> <template #item.mode="{ item }">
<VChip variant="outlined" color="primary" size="small">{{ <VChip
variant="outlined"
color="primary"
size="small"
>
{{
TransferDict[item.raw.mode] TransferDict[item.raw.mode]
}}</VChip> }}
</VChip>
</template> </template>
<template #item.status="{ item }"> <template #item.status="{ item }">
<VChip :color="getStatusColor(item.raw.status)" size="small"> <VChip
:color="getStatusColor(item.raw.status)"
size="small"
>
{{ item.raw.status ? "成功" : "失败" }} {{ item.raw.status ? "成功" : "失败" }}
</VChip> </VChip>
</template> </template>
@@ -267,28 +287,36 @@ const dropdownItems = ref([
<template #item.actions="{ item }"> <template #item.actions="{ item }">
<IconBtn> <IconBtn>
<VIcon icon="mdi-dots-vertical" /> <VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click> <VMenu
activator="parent"
close-on-content-click
>
<VList> <VList>
<VListItem <VListItem
v-for="(menu, i) in dropdownItems" v-for="(menu, i) in dropdownItems"
:key="i"
variant="plain" variant="plain"
:base-color="menu.props.color" :base-color="menu.props.color"
:key="i"
@click="menu.props.click(item.raw)" @click="menu.props.click(item.raw)"
> >
<template #prepend> <template #prepend>
<VIcon :icon="menu.props.prependIcon"></VIcon> <VIcon :icon="menu.props.prependIcon" />
</template> </template>
<VListItemTitle v-text="menu.title"></VListItemTitle> <VListItemTitle v-text="menu.title" />
</VListItem> </VListItem>
</VList> </VList>
</VMenu> </VMenu>
</IconBtn> </IconBtn>
</template> </template>
<template #no-data> 没有数据 </template> <template #no-data>
没有数据
</template>
</VDataTableServer> </VDataTableServer>
</VCard> </VCard>
<VDialog v-model="redoDialog" max-width="600"> <VDialog
v-model="redoDialog"
max-width="600"
>
<!-- Dialog Content --> <!-- Dialog Content -->
<VCard title="重新整理"> <VCard title="重新整理">
<VCardText> <VCardText>
@@ -305,13 +333,18 @@ const dropdownItems = ref([
<VCardActions> <VCardActions>
<VSpacer /> <VSpacer />
<VBtn @click="rehandleHistory" @keydown.enter="rehandleHistory"> 确定 </VBtn> <VBtn
@click="rehandleHistory"
@keydown.enter="rehandleHistory"
>
确定
</VBtn>
</VCardActions> </VCardActions>
</VCard> </VCard>
</VDialog> </VDialog>
</template> </template>
<style type="scss"> <style lang="scss">
.v-table th { .v-table th {
white-space: nowrap; white-space: nowrap;
} }
+467
View File
@@ -0,0 +1,467 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { requiredValidator } from '@/@validators'
import api from '@/api'
import type { User } from '@/api/types'
import avatar1 from '@images/avatars/avatar-1.png'
const isNewPasswordVisible = ref(false)
const isConfirmPasswordVisible = ref(false)
const isPasswordVisible = ref(false)
const newPassword = ref('')
const confirmPassword = ref('')
// 提示框
const $toast = useToast()
const refInputEl = ref<HTMLElement>()
// 新增用户窗口
const addUserDialog = ref(false)
// 新增用户表单
const userForm = reactive({
name: '',
password: '',
email: '',
})
// 当前用户信息
const accountInfo = ref<User>({
id: 0,
name: '',
password: '',
email: '',
is_active: false,
is_superuser: false,
avatar: '',
})
// 所有用户信息
const allUsers = ref<User[]>([])
// changeAvatar function
function changeAvatar(file: Event) {
const fileReader = new FileReader()
const { files } = file.target as HTMLInputElement
if (files && files.length > 0) {
fileReader.readAsDataURL(files[0])
fileReader.onload = () => {
if (typeof fileReader.result === 'string') {
accountInfo.value.avatar = fileReader.result
saveAccountInfo()
}
}
}
}
// reset avatar image
function resetAvatar() {
accountInfo.value.avatar = avatar1
}
// 调用API,加载当前用户数据
async function loadAccountInfo() {
try {
const user: User = await api.get('user/current')
accountInfo.value = user
if (!accountInfo.value.avatar)
accountInfo.value.avatar = avatar1
}
catch (error) {
console.log(error)
}
}
// 保存用户信息
async function saveAccountInfo() {
if (newPassword.value || confirmPassword.value) {
if (newPassword.value !== confirmPassword.value) {
$toast.error('两次输入的密码不一致')
return
}
accountInfo.value.password = newPassword.value
}
try {
const result: { [key: string]: any } = await api.put('user', accountInfo.value)
if (result.success)
$toast.success('用户信息保存成功!')
else
$toast.error(`用户信息保存失败:${result.message}`)
}
catch (error) {
console.log(error)
}
}
// 调用API,查询所有用户
async function loadAllUsers() {
try {
const result: User[] = await api.get('/user')
allUsers.value = result
}
catch (error) {
console.log(error)
}
}
// 删除用户
async function deleteUser(user: User) {
try {
const result: { [key: string]: any } = await api.delete(`user/${user.name}`)
if (result.success) {
$toast.success('用户删除成功!')
loadAllUsers()
}
else {
$toast.error(`用户删除失败:${result.message}`)
}
}
catch (error) {
console.log(error)
}
}
// 冻结用户
async function deactivateUser(user: User) {
try {
user.is_active = !user.is_active
const result: { [key: string]: any } = await api.put('user', user)
if (result.success) {
$toast.success('用户冻结成功!')
loadAllUsers()
}
else {
$toast.error(`用户冻结失败:${result.message}`)
}
}
catch (error) {
console.log(error)
}
}
// 新增用户
async function addUser() {
try {
const result: { [key: string]: any } = await api.post('user', userForm)
if (result.success) {
$toast.success('用户新增成功!')
loadAllUsers()
addUserDialog.value = false
}
else {
$toast.error(`用户新增失败:${result.message}`)
}
}
catch (error) {
console.log(error)
}
}
// 加载当前用户数据
onMounted(() => {
loadAccountInfo()
loadAllUsers()
})
</script>
<template>
<VRow>
<VCol cols="12">
<VCard title="个人信息">
<VCardText class="d-flex">
<!-- 👉 Avatar -->
<VAvatar
rounded="lg"
size="100"
class="me-6"
:image="accountInfo.avatar"
/>
<!-- 👉 Upload Photo -->
<form class="d-flex flex-column justify-center gap-5">
<div class="d-flex flex-wrap gap-2">
<VBtn
color="primary"
@click="refInputEl?.click()"
>
<VIcon
icon="mdi-cloud-upload-outline"
class="d-sm-none"
/>
<span class="d-none d-sm-block">上传头像</span>
</VBtn>
<input
ref="refInputEl"
type="file"
name="file"
accept=".jpeg,.png,.jpg,GIF"
hidden
@input="changeAvatar"
>
<VBtn
type="reset"
color="error"
variant="tonal"
@click="resetAvatar"
>
<span class="d-none d-sm-block">重置</span>
<VIcon
icon="mdi-refresh"
class="d-sm-none"
/>
</VBtn>
</div>
<p class="text-body-1 mb-0">
允许 JPGGIF PNG 格式 最大尽寸 800K
</p>
</form>
</VCardText>
<VDivider />
<VCardText>
<!-- 👉 Form -->
<VForm class="mt-6">
<VRow>
<!-- 👉 Name -->
<VCol
md="6"
cols="12"
>
<VTextField
v-model="accountInfo.name"
readonly
label="用户名"
/>
</VCol>
<!-- 👉 Email -->
<VCol
cols="12"
md="6"
>
<VTextField
v-model="accountInfo.email"
label="邮箱"
type="email"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<!-- 👉 new password -->
<VTextField
v-model="newPassword"
:type="isNewPasswordVisible ? 'text' : 'password'"
:append-inner-icon="
isNewPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'
"
label="新密码"
autocomplete="new-password"
@click:append-inner="isNewPasswordVisible = !isNewPasswordVisible"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<!-- 👉 confirm password -->
<VTextField
v-model="confirmPassword"
:type="isConfirmPasswordVisible ? 'text' : 'password'"
:append-inner-icon="
isConfirmPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'
"
label="确认新密码"
@click:append-inner="
isConfirmPasswordVisible = !isConfirmPasswordVisible
"
/>
</VCol>
<!-- 👉 Form Actions -->
<VCol
cols="12"
class="d-flex flex-wrap gap-4"
>
<VBtn @click="saveAccountInfo">
保存
</VBtn>
</VCol>
</VRow>
</VForm>
</VCardText>
</VCard>
</VCol>
<VCol
v-if="accountInfo.is_superuser"
cols="12"
>
<!-- 👉 Accounts -->
<VCard title="所有用户">
<template #append>
<IconBtn @click.stop="addUserDialog = true">
<VIcon icon="mdi-plus" />
</IconBtn>
</template>
<VTable class="text-no-wrap">
<thead>
<tr>
<th scope="col">
用户名
</th>
<th scope="col">
邮箱
</th>
<th scope="col">
状态
</th>
<th scope="col">
管理员
</th>
<th
scope="col"
class="w-5"
/>
</tr>
</thead>
<tbody>
<tr
v-for="user in allUsers"
:key="user.name"
>
<td>
{{ user.name }}
</td>
<td>{{ user.email }}</td>
<td>
<VChip
v-if="user.is_active"
color="success"
text-color="white"
>
激活
</VChip>
<VChip
v-else
color="error"
text-color="white"
>
冻结
</VChip>
</td>
<td>{{ user.is_superuser ? "是" : "否" }}</td>
<td>
<IconBtn v-show="!user.is_superuser">
<VIcon icon="mdi-dots-vertical" />
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
variant="plain"
@click="deactivateUser(user)"
>
<template #prepend>
<VIcon icon="mdi-lock" />
</template>
<VListItemTitle>
{{
user.is_active ? "冻结" : "解冻"
}}
</VListItemTitle>
</VListItem>
<VListItem
variant="plain"
base-color="error"
@click="deleteUser(user)"
>
<template #prepend>
<VIcon icon="mdi-delete" />
</template>
<VListItemTitle>删除</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</td>
</tr>
</tbody>
</VTable>
</VCard>
</VCol>
</VRow>
<!-- 站点编辑弹窗 -->
<VDialog
v-model="addUserDialog"
max-width="800"
persistent
>
<!-- Dialog Content -->
<VCard title="新增用户">
<VCardText>
<VForm @submit.prevent="() => {}">
<VRow>
<VCol
cols="12"
md="6"
>
<VTextField
v-model="userForm.name"
label="用户名"
:rules="[requiredValidator]"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<VTextField
v-model="userForm.password"
label="密码"
:rules="[requiredValidator]"
:type="isPasswordVisible ? 'text' : 'password'"
:append-inner-icon="
isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'
"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<VTextField
v-model="userForm.email"
label="邮箱"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VBtn @click="addUserDialog = false">
取消
</VBtn>
<VSpacer />
<VBtn @click="addUser">
确定
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
@@ -0,0 +1,112 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import api from '@/api'
import type { NotificationSwitch } from '@/api/types'
const messagemTypes = ref<NotificationSwitch[]>([])
// 提示框
const $toast = useToast()
// 调用API查询消息开关
async function loadNotificationSwitchs() {
try {
const result: NotificationSwitch[] = await api.get('message/switchs')
messagemTypes.value = result
}
catch (error) {
console.log(error)
}
}
// 调用API保存消息开关
async function saveNotificationSwitchs() {
try {
const result: { [key: string]: any } = await api.post(
'message/switchs',
messagemTypes.value,
)
if (result.success)
$toast.success('保存通知消息设置成功')
else
$toast.error('保存通知消息设置失败!')
// messagemTypes.value = messagemTypes.value
}
catch (error) {
console.log(error)
}
}
onMounted(() => {
loadNotificationSwitchs()
})
</script>
<template>
<VCard title="消息通知">
<VCardText> 对应消息类型只会发送给选中的消息渠道 </VCardText>
<VTable class="text-no-wrap">
<thead>
<tr>
<th scope="col">
消息类型
</th>
<th scope="col">
微信
</th>
<th scope="col">
Telegram
</th>
<th scope="col">
Slack
</th>
</tr>
</thead>
<tbody>
<tr
v-for="message in messagemTypes"
:key="message.mtype"
>
<td>
{{ message.mtype }}
</td>
<td>
<VCheckbox v-model="message.wechat" />
</td>
<td>
<VCheckbox v-model="message.telegram" />
</td>
<td>
<VCheckbox v-model="message.slack" />
</td>
</tr>
<tr v-if="messagemTypes.length === 0">
<td
colspan="4"
class="text-center"
>
没有设置任何通知渠道
</td>
</tr>
</tbody>
</VTable>
<VDivider />
<VCardText>
<VForm @submit.prevent="() => {}">
<div class="d-flex flex-wrap gap-4 mt-4">
<VBtn
mtype="submit"
@click="saveNotificationSwitchs"
>
保存
</VBtn>
</div>
</VForm>
</VCardText>
</VCard>
</template>
@@ -1,152 +1,164 @@
<script lang="ts" setup> <script lang="ts" setup>
import api from "@/api"; import { useToast } from 'vue-toast-notification'
import FilterRuleCard from "@/components/cards/FilterRuleCard.vue"; import api from '@/api'
import { useToast } from "vue-toast-notification"; import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
// //
interface FilterCard { interface FilterCard {
// //
pri: string; pri: string
// //
rules: string[]; rules: string[]
// //
visible: boolean; visible: boolean
} }
// //
const $toast = useToast(); const $toast = useToast()
// //
const selectedTorrentPriority = ref<string>("seeder"); const selectedTorrentPriority = ref<string>('seeder')
// //
const TorrentPriorityItems = [ const TorrentPriorityItems = [
{ title: "站点优先", value: "site" }, { title: '站点优先', value: 'site' },
{ title: "做种数优先", value: "seeder" }, { title: '做种数优先', value: 'seeder' },
]; ]
//
const filterCards = ref<FilterCard[]>([])
// //
const queryCustomFilters = async () => { async function queryCustomFilters() {
try { try {
const result: { [key: string]: any } = await api.get("system/setting/FilterRules"); const result: { [key: string]: any } = await api.get('system/setting/FilterRules')
if (result.success) { if (result.success) {
// //
const groups = result.data?.value.split(">"); const groups = result.data?.value.split('>')
// //
filterCards.value = groups?.map((group: string, index: number) => { filterCards.value = groups?.map((group: string, index: number) => {
return { return {
pri: (index + 1).toString(), pri: (index + 1).toString(),
rules: group.split("&"), rules: group.split('&'),
visible: true, visible: true,
};
});
} }
} catch (error) { })
console.log(error); }
}
catch (error) {
console.log(error)
}
} }
};
// //
const queryTorrentPriority = async () => { async function queryTorrentPriority() {
try { try {
const result: { [key: string]: any } = await api.get( const result: { [key: string]: any } = await api.get(
"system/setting/TorrentsPriority" 'system/setting/TorrentsPriority',
); )
selectedTorrentPriority.value = result.data?.value;
} catch (error) { selectedTorrentPriority.value = result.data?.value
console.log(error); }
catch (error) {
console.log(error)
}
} }
};
// //
const saveCustomFilters = async () => { async function saveCustomFilters() {
try { try {
// //
if (filterCards.value.length === 0) { if (filterCards.value.length === 0)
return; return
}
// //
const value = filterCards.value const value = filterCards.value
.filter((card) => card.rules.length > 0) .filter(card => card.rules.length > 0)
.map((card) => card.rules.join("&")) .map(card => card.rules.join('&'))
.join(">"); .join('>')
// //
const result: { [key: string]: any } = await api.post( const result: { [key: string]: any } = await api.post(
"system/setting/FilterRules", 'system/setting/FilterRules',
value value,
); )
if (result.success) {
$toast.success("过滤规则保存成功"); if (result.success)
} else { $toast.success('过滤规则保存成功')
$toast.error("过滤规则保存失败!"); else
$toast.error('过滤规则保存失败!')
}
catch (error) {
console.log(error)
} }
} catch (error) {
console.log(error);
} }
};
// //
const saveTorrentPriority = async () => { async function saveTorrentPriority() {
try { try {
// //
const result: { [key: string]: any } = await api.post( const result: { [key: string]: any } = await api.post(
"system/setting/TorrentsPriority", 'system/setting/TorrentsPriority',
selectedTorrentPriority.value selectedTorrentPriority.value,
); )
if (result.success) {
$toast.success("优先规则保存成功");
} else {
$toast.error("优先规则保存失败!");
}
} catch (error) {
console.log(error);
}
};
// if (result.success)
const filterCards = ref<FilterCard[]>([]); $toast.success('优先规则保存成功')
else
$toast.error('优先规则保存失败!')
}
catch (error) {
console.log(error)
}
}
// //
const updateFilterCardValue = (pri: string, rules: string[]) => { function updateFilterCardValue(pri: string, rules: string[]) {
const card = filterCards.value.find((card) => card.pri === pri); const card = filterCards.value.find(card => card.pri === pri)
if (card) { if (card)
card.rules = rules; card.rules = rules
} }
};
// //
const filterCardClose = (pri: string) => { function filterCardClose(pri: string) {
// //
const index = filterCards.value.findIndex((card) => card.pri === pri); const index = filterCards.value.findIndex(card => card.pri === pri)
if (index !== -1) { if (index !== -1) {
// 使 splice // 使 splice
const updatedCards = [...filterCards.value]; const updatedCards = [...filterCards.value]
updatedCards.splice(index, 1);
updatedCards.splice(index, 1)
// //
updatedCards.forEach((card, i) => { updatedCards.forEach((card, i) => {
card.pri = (i + 1).toString(); card.pri = (i + 1).toString()
}); })
// filterCards.value // filterCards.value
filterCards.value = updatedCards; filterCards.value = updatedCards
}
} }
};
// //
const addFilterCard = () => { function addFilterCard() {
// //
const pri = (filterCards.value.length + 1).toString(); const pri = (filterCards.value.length + 1).toString()
// //
const newCard: FilterCard = { pri, rules: [], visible: true }; const newCard: FilterCard = { pri, rules: [], visible: true }
// //
filterCards.value.push(newCard); filterCards.value.push(newCard)
}; }
onMounted(() => { onMounted(() => {
queryTorrentPriority(); queryTorrentPriority()
queryCustomFilters(); queryCustomFilters()
}); })
</script> </script>
<template> <template>
@@ -168,8 +180,18 @@ onMounted(() => {
</div> </div>
</VCardItem> </VCardItem>
<VCardItem> <VCardItem>
<VBtn type="submit" @click="saveCustomFilters" class="me-2"> 保存 </VBtn> <VBtn
<VBtn @click="addFilterCard" color="success" variant="tonal"> type="submit"
class="me-2"
@click="saveCustomFilters"
>
保存
</VBtn>
<VBtn
color="success"
variant="tonal"
@click="addFilterCard"
>
<VIcon icon="mdi-plus" /> <VIcon icon="mdi-plus" />
<span class="d-none d-sm-block">增加规则</span> <span class="d-none d-sm-block">增加规则</span>
</VBtn> </VBtn>
@@ -184,16 +206,22 @@ onMounted(() => {
:items="TorrentPriorityItems" :items="TorrentPriorityItems"
label="优先规则" label="优先规则"
outlined outlined
></VSelect> />
</VCardText> </VCardText>
<VCardItem> <VCardItem>
<VBtn type="submit" @click="saveTorrentPriority"> 保存 </VBtn> <VBtn
type="submit"
@click="saveTorrentPriority"
>
保存
</VBtn>
</VCardItem> </VCardItem>
</VCard> </VCard>
</VCol> </VCol>
</VRow> </VRow>
</template> </template>
<style type="scss">
<style lang="scss">
.grid-filterrule-card { .grid-filterrule-card {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
padding-block-end: 1rem; padding-block-end: 1rem;
+152
View File
@@ -0,0 +1,152 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import api from '@/api'
import type { Site } from '@/api/types'
// 提示框
const $toast = useToast()
// 选中站点
const selectedSites = ref<number[]>([])
// 所有站点
const allSites = ref<Site[]>([])
// 站点重置
const isConfirmResetSites = ref(false)
// 站点重置按钮文本
const resetSitesText = ref('重置站点数据')
// 站点重置按钮可用状态
const resetSitesDisabled = ref(false)
// 查询所有站点
async function querySites() {
try {
const data: Site[] = await api.get('site')
// 过滤站点,只有启用的站点才显示
allSites.value = data.filter(item => item.is_active)
querySelectedSites()
}
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)
}
}
// 保存用户选中的站点
async function saveSelectedSites() {
try {
// 用户名密码
const result: { [key: string]: any } = await api.post(
'system/setting/IndexerSites',
selectedSites.value,
)
if (result.success)
$toast.success('索引站点保存成功')
else
$toast.error('索引站点保存失败!')
}
catch (error) {
console.log(error)
}
}
// 重置站点
async function resetSites() {
try {
resetSitesDisabled.value = true
resetSitesText.value = '正在重置...'
const result: { [key: string]: any } = await api.get('site/reset')
if (result.success) {
$toast.success('站点重置成功,请等待CookieCloud同步完成!')
querySites()
}
else {
$toast.error('站点重置失败!')
}
resetSitesDisabled.value = false
resetSitesText.value = '重置站点数据'
}
catch (error) {
console.log(error)
}
}
onMounted(() => {
querySites()
})
</script>
<template>
<VRow>
<VCol cols="12">
<VCard title="索引站点">
<VCardSubtitle> 只有选中的站点才会在搜索和订阅中使用 </VCardSubtitle>
<VCardItem>
<VChipGroup
v-model="selectedSites"
column
multiple
>
<VChip
v-for="site in allSites"
:key="site.id"
filter
variant="outlined"
:value="site.id"
>
{{ site.name }}
</VChip>
</VChipGroup>
</VCardItem>
<VCardItem>
<VBtn
type="submit"
@click="saveSelectedSites"
>
保存
</VBtn>
</VCardItem>
</VCard>
</VCol>
<VCol cols="12">
<VCard title="站点重置">
<VCardText>
<div>
<VCheckbox
v-model="isConfirmResetSites"
label="确认删除所有站点数据并重新同步"
/>
</div>
<VBtn
:disabled="!isConfirmResetSites || resetSitesDisabled"
color="error"
class="mt-3"
@click="resetSites"
>
{{ resetSitesText }}
</VBtn>
</VCardText>
</VCard>
</VCol>
</VRow>
</template>
@@ -1,80 +1,86 @@
<script lang="ts" setup> <script lang="ts" setup>
import api from "@/api"; import { useToast } from 'vue-toast-notification'
import { useToast } from "vue-toast-notification"; import api from '@/api'
// //
const $toast = useToast(); const $toast = useToast()
// //
const customIdentifiers = ref(""); const customIdentifiers = ref('')
// //
const customReleaseGroups = ref(""); const customReleaseGroups = ref('')
// //
const queryCustomIdentifiers = async () => { async function queryCustomIdentifiers() {
try { try {
const result: { [key: string]: any } = await api.get( const result: { [key: string]: any } = await api.get(
"system/setting/CustomIdentifiers" 'system/setting/CustomIdentifiers',
); )
customIdentifiers.value = result.data?.value.join("\n");
} catch (error) { customIdentifiers.value = result.data?.value.join('\n')
console.log(error); }
catch (error) {
console.log(error)
}
} }
};
// //
const queryCustomReleaseGroups = async () => { async function queryCustomReleaseGroups() {
try { try {
const result: { [key: string]: any } = await api.get( const result: { [key: string]: any } = await api.get(
"system/setting/CustomReleaseGroups" 'system/setting/CustomReleaseGroups',
); )
customReleaseGroups.value = result.data?.value.join("\n");
} catch (error) { customReleaseGroups.value = result.data?.value.join('\n')
console.log(error); }
catch (error) {
console.log(error)
}
} }
};
// //
const saveCustomIdentifiers = async () => { async function saveCustomIdentifiers() {
try { try {
// //
const result: { [key: string]: any } = await api.post( const result: { [key: string]: any } = await api.post(
"system/setting/CustomIdentifiers", 'system/setting/CustomIdentifiers',
customIdentifiers.value.split("\n") customIdentifiers.value.split('\n'),
); )
if (result.success) {
$toast.success("自定义识别词保存成功"); if (result.success)
} else { $toast.success('自定义识别词保存成功')
$toast.error("自定义识别词保存失败!"); else
$toast.error('自定义识别词保存失败!')
}
catch (error) {
console.log(error)
} }
} catch (error) {
console.log(error);
} }
};
// //
const saveCustomReleaseGroups = async () => { async function saveCustomReleaseGroups() {
try { try {
// //
const result: { [key: string]: any } = await api.post( const result: { [key: string]: any } = await api.post(
"system/setting/CustomReleaseGroups", 'system/setting/CustomReleaseGroups',
customReleaseGroups.value.split("\n") customReleaseGroups.value.split('\n'),
); )
if (result.success) {
$toast.success("自定义制作组/字幕组保存成功"); if (result.success)
} else { $toast.success('自定义制作组/字幕组保存成功')
$toast.error("自定义制作组/字幕组保存失败!"); else
$toast.error('自定义制作组/字幕组保存失败!')
}
catch (error) {
console.log(error)
} }
} catch (error) {
console.log(error);
} }
};
onMounted(() => { onMounted(() => {
queryCustomIdentifiers(); queryCustomIdentifiers()
queryCustomReleaseGroups(); queryCustomReleaseGroups()
}); })
</script> </script>
<template> <template>
@@ -90,11 +96,15 @@ onMounted(() => {
屏蔽词 屏蔽词
被替换词 => 替换词 被替换词 => 替换词
前定位词 <> 后定位词 >> 偏移量EP" 前定位词 <> 后定位词 >> 偏移量EP"
> />
</VTextarea>
</VCardItem> </VCardItem>
<VCardItem> <VCardItem>
<VBtn type="submit" @click="saveCustomIdentifiers"> 保存 </VBtn> <VBtn
type="submit"
@click="saveCustomIdentifiers"
>
保存
</VBtn>
</VCardItem> </VCardItem>
</VCard> </VCard>
</VCol> </VCol>
@@ -106,11 +116,15 @@ onMounted(() => {
v-model="customReleaseGroups" v-model="customReleaseGroups"
auto-grow auto-grow
placeholder="支持正则表达式,特殊字符需要\转义,一行代表一个制作组/字幕组" placeholder="支持正则表达式,特殊字符需要\转义,一行代表一个制作组/字幕组"
> />
</VTextarea>
</VCardItem> </VCardItem>
<VCardItem> <VCardItem>
<VBtn type="submit" @click="saveCustomReleaseGroups"> 保存 </VBtn> <VBtn
type="submit"
@click="saveCustomReleaseGroups"
>
保存
</VBtn>
</VCardItem> </VCardItem>
</VCard> </VCard>
</VCol> </VCol>
+12 -12
View File
@@ -1,8 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import api from '@/api'; import api from '@/api'
import { Site } from '@/api/types'; import type { Site } from '@/api/types'
import SiteCard from '@/components/cards/SiteCard.vue'; import SiteCard from '@/components/cards/SiteCard.vue'
import NoDataFound from "@/components/NoDataFound.vue"; import NoDataFound from '@/components/NoDataFound.vue'
// 数据列表 // 数据列表
const dataList = ref<Site[]>([]) const dataList = ref<Site[]>([])
@@ -11,11 +11,12 @@ const dataList = ref<Site[]>([])
const isRefreshed = ref(false) const isRefreshed = ref(false)
// 获取站点列表数据 // 获取站点列表数据
const fetchData = async () => { async function fetchData() {
try { try {
dataList.value = await api.get('site') dataList.value = await api.get('site')
isRefreshed.value = true isRefreshed.value = true
} catch (error) { }
catch (error) {
console.error(error) console.error(error)
} }
} }
@@ -26,14 +27,14 @@ onBeforeMount(fetchData)
<template> <template>
<VProgressCircular <VProgressCircular
class="centered"
v-if="!isRefreshed" v-if="!isRefreshed"
class="centered"
indeterminate indeterminate
color="primary" color="primary"
></VProgressCircular> />
<div <div
class="grid gap-3 grid-site-card"
v-if="dataList.length > 0" v-if="dataList.length > 0"
class="grid gap-3 grid-site-card"
> >
<SiteCard <SiteCard
v-for="data in dataList" v-for="data in dataList"
@@ -46,11 +47,10 @@ onBeforeMount(fetchData)
error-code="404" error-code="404"
error-title="没有站点" error-title="没有站点"
error-description="已添加并支持的站点将会在这里显示" error-description="已添加并支持的站点将会在这里显示"
> />
</NoDataFound>
</template> </template>
<style type="scss"> <style lang="scss">
.grid-site-card { .grid-site-card {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
padding-block-end: 1rem; padding-block-end: 1rem;
+50 -51
View File
@@ -1,102 +1,99 @@
<script lang="ts" setup> <script lang="ts" setup>
import { parseDate } from "@/@core/utils/formatters"; import type { CalendarOptions } from '@fullcalendar/core'
import api from "@/api"; import dayGridPlugin from '@fullcalendar/daygrid'
import type { MediaInfo, Subscribe, TmdbEpisode } from "@/api/types"; import interactionPlugin from '@fullcalendar/interaction'
import dayGridPlugin from "@fullcalendar/daygrid"; import timeGridPlugin from '@fullcalendar/timegrid'
import interactionPlugin from "@fullcalendar/interaction"; import FullCalendar from '@fullcalendar/vue3'
import timeGridPlugin from "@fullcalendar/timegrid"; import type { Ref } from 'vue'
import FullCalendar from "@fullcalendar/vue3"; import type { MediaInfo, Subscribe, TmdbEpisode } from '@/api/types'
import api from '@/api'
import { parseDate } from '@/@core/utils/formatters'
// 日历属性 // 日历属性
const calendarOptions = ref({ const calendarOptions: Ref<CalendarOptions> = ref({
height: "auto", height: 'auto',
locale: "zh-cn", locale: 'zh-cn',
themeSystem: "standard", themeSystem: 'standard',
buttonText: { buttonText: {
today: "今天", today: '今天',
month: "月", month: '月',
week: "周", week: '周',
day: "日", day: '日',
list: "列表", list: '列表',
}, },
plugins: [ plugins: [
dayGridPlugin, dayGridPlugin,
timeGridPlugin, timeGridPlugin,
interactionPlugin, // needed for dateClick interactionPlugin, // needed for dateClick
], ],
initialView: "dayGridMonth", initialView: 'dayGridMonth',
weekends: false, weekends: false,
headerToolbar: { headerToolbar: {
left: "prev", left: 'prev',
center: "title", center: 'title',
right: "next", right: 'next',
}, },
views: { views: {
week: { week: {
titleFormat: { day: "numeric" }, titleFormat: { day: 'numeric' },
}, },
}, },
events: [], events: [],
}); })
// 调用API查询所有订阅 // 调用API查询所有订阅
const getSubscribes = async () => { async function getSubscribes() {
try { try {
const subscribes: Subscribe[] = await api.get("subscribe"); const subscribes: Subscribe[] = await api.get('subscribe')
const events = await Promise.all( const events = await Promise.all(
subscribes.map(async (subscribe) => { subscribes.map(async (subscribe) => {
// 如果是电影直接返回 // 如果是电影直接返回
if (subscribe.type === "电影") { if (subscribe.type === '电影') {
// 调用API查询TMDB详情 // 调用API查询TMDB详情
const movie: MediaInfo = await api.get(`tmdb/${subscribe.tmdbid}`, { const movie: MediaInfo = await api.get(`tmdb/${subscribe.tmdbid}`, {
params: { tmdbid: subscribe.tmdbid, type_name: subscribe.type }, params: { tmdbid: subscribe.tmdbid, type_name: subscribe.type },
}); })
return { return {
title: subscribe.name, title: subscribe.name,
start: parseDate(movie.release_date || ""), start: parseDate(movie.release_date || ''),
allDay: false, allDay: false,
posterPath: subscribe.poster, posterPath: subscribe.poster,
mediaType: subscribe.type, mediaType: subscribe.type,
}; }
} else { }
else {
// 调用API查询集信息 // 调用API查询集信息
const episodes: TmdbEpisode[] = await api.get( const episodes: TmdbEpisode[] = await api.get(
`tmdb/${subscribe.tmdbid}/${subscribe.season}` `tmdb/${subscribe.tmdbid}/${subscribe.season}`,
); )
return episodes.map((episode) => { return episodes.map((episode) => {
return { return {
title: subscribe.name, title: subscribe.name,
subtitle: `${episode.episode_number}`, subtitle: `${episode.episode_number}`,
start: parseDate(episode.air_date || ""), start: parseDate(episode.air_date || ''),
allDay: false, allDay: false,
posterPath: subscribe.poster, posterPath: subscribe.poster,
mediaType: subscribe.type, mediaType: subscribe.type,
};
});
} }
}) })
);
calendarOptions.value.events = events.flat();
} catch (error) {
console.error(error);
} }
}; }),
)
// 根据 type 返回不同的图标 calendarOptions.value.events = events.flat()
const getIcon = (type: string) => { }
if (type === "电影") { catch (error) {
return "mdi-movie-roll"; console.error(error)
} else if (type === "电视剧") { }
return "mdi-television-box";
} else {
return "mdi-help-circle";
} }
};
// 页面加载时调用API查询所有订阅 // 页面加载时调用API查询所有订阅
onMounted(() => { onMounted(() => {
getSubscribes(); getSubscribes()
}); })
</script> </script>
<template> <template>
@@ -117,7 +114,9 @@ onMounted(() => {
</div> </div>
<VDivider :vertical="$vuetify.display.mdAndUp" /> <VDivider :vertical="$vuetify.display.mdAndUp" />
<div> <div>
<VCardSubtitle class="pa-2 font-bold">{{ arg.event.title }}</VCardSubtitle> <VCardSubtitle class="pa-2 font-bold">
{{ arg.event.title }}
</VCardSubtitle>
<VCardText class="pa-0 px-2"> <VCardText class="pa-0 px-2">
{{ arg.event.extendedProps.subtitle }} {{ arg.event.extendedProps.subtitle }}
</VCardText> </VCardText>
+39 -30
View File
@@ -1,73 +1,82 @@
<script lang="ts" setup> <script lang="ts" setup>
import api from "@/api"; import PullRefresh from 'pull-refresh-vue3'
import type { Subscribe } from "@/api/types"; import api from '@/api'
import NoDataFound from "@/components/NoDataFound.vue"; import type { Subscribe } from '@/api/types'
import SubscribeCard from "@/components/cards/SubscribeCard.vue"; import NoDataFound from '@/components/NoDataFound.vue'
import PullRefresh from 'pull-refresh-vue3'; import SubscribeCard from '@/components/cards/SubscribeCard.vue'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
type: String, type: String,
}); })
// 是否刷新过 // 是否刷新过
const isRefreshed = ref(false) const isRefreshed = ref(false)
// 数据列表 // 数据列表
const dataList = ref<Subscribe[]>([]); const dataList = ref<Subscribe[]>([])
// 获取订阅列表数据 // 获取订阅列表数据
const fetchData = async () => { async function fetchData() {
try { try {
dataList.value = await api.get("subscribe"); dataList.value = await api.get('subscribe')
isRefreshed.value = true isRefreshed.value = true
} catch (error) {
console.error(error);
} }
}; catch (error) {
console.error(error)
}
}
// 加载时获取数据 // 加载时获取数据
onBeforeMount(fetchData); onBeforeMount(fetchData)
// 刷新状态 // 刷新状态
const loading = ref(false); const loading = ref(false)
// 下拉刷新 // 下拉刷新
const onRefresh = () => { function onRefresh() {
loading.value = true; loading.value = true
fetchData(); fetchData()
loading.value = false; loading.value = false
}; }
// 过滤数据 // 过滤数据
const filteredDataList = computed(() => { const filteredDataList = computed(() => {
return dataList.value.filter((data) => data.type === props.type); return dataList.value.filter(data => data.type === props.type)
}); })
</script> </script>
<template> <template>
<VProgressCircular <VProgressCircular
class="centered"
v-if="!isRefreshed" v-if="!isRefreshed"
class="centered"
indeterminate indeterminate
color="primary" color="primary"
></VProgressCircular> />
<PullRefresh v-model="loading" @refresh="onRefresh"> <PullRefresh
<div class="grid gap-3 grid-subscribe-card" v-model="loading"
v-if="filteredDataList.length > 0"> @refresh="onRefresh"
<SubscribeCard v-for="data in filteredDataList" :key="data.id" :media="data" /> >
<div
v-if="filteredDataList.length > 0"
class="grid gap-3 grid-subscribe-card"
>
<SubscribeCard
v-for="data in filteredDataList"
:key="data.id"
:media="data"
/>
</div> </div>
<NoDataFound <NoDataFound
v-if="filteredDataList.length === 0 && isRefreshed" v-if="filteredDataList.length === 0 && isRefreshed"
error-code="404" error-code="404"
error-title="没有订阅" error-title="没有订阅"
error-description="请通过搜索添加电影电视剧订阅" error-description="请通过搜索添加电影电视剧订阅"
> />
</NoDataFound>
</PullRefresh> </PullRefresh>
</template> </template>
<style type="scss"> <style lang="scss">
.grid-subscribe-card { .grid-subscribe-card {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
padding-block-end: 1rem; padding-block-end: 1rem;
+59 -40
View File
@@ -1,48 +1,50 @@
<script setup lang="ts"> <script setup lang="ts">
import { requiredValidator } from "@/@validators"; import { reactive, ref } from 'vue'
import api from "@/api"; import { requiredValidator } from '@/@validators'
import { Context } from "@/api/types"; import api from '@/api'
import { reactive, ref } from "vue"; import type { Context } from '@/api/types'
// 识别结果 // 识别结果
const nameTestResult = ref<Context>(); const nameTestResult = ref<Context>()
// 名称识别表单 // 名称识别表单
const nameTestForm = reactive({ const nameTestForm = reactive({
title: "", title: '',
subtitle: "", subtitle: '',
}); })
// 识别按钮状态 // 识别按钮状态
const nameTestLoading = ref(false); const nameTestLoading = ref(false)
// 识别按钮文本 // 识别按钮文本
const nameTestText = ref("识别"); const nameTestText = ref('识别')
// 是否显示结果 // 是否显示结果
const showResult = ref(false); const showResult = ref(false)
// 调用API识别 // 调用API识别
const nameTest = async () => { async function nameTest() {
if (!nameTestForm.title) { if (!nameTestForm.title)
return; return
}
try { try {
nameTestLoading.value = true; nameTestLoading.value = true
nameTestText.value = "识别中..."; nameTestText.value = '识别中...'
showResult.value = false; showResult.value = false
nameTestResult.value = await api.get("media/recognize", { nameTestResult.value = await api.get('media/recognize', {
params: { params: {
title: nameTestForm.title, title: nameTestForm.title,
subtitle: nameTestForm.subtitle, subtitle: nameTestForm.subtitle,
}, },
}); })
nameTestLoading.value = false; nameTestLoading.value = false
nameTestText.value = "重新识别"; nameTestText.value = '重新识别'
showResult.value = true; showResult.value = true
} catch (error) { }
console.error(error); catch (error) {
console.error(error)
}
} }
};
</script> </script>
<template> <template>
@@ -56,14 +58,25 @@ const nameTest = async () => {
/> />
</VCol> </VCol>
<VCol cols="12"> <VCol cols="12">
<VTextarea v-model="nameTestForm.subtitle" label="副标题" rows="2" auto-grow /> <VTextarea
v-model="nameTestForm.subtitle"
label="副标题"
rows="2"
auto-grow
/>
</VCol> </VCol>
</VRow> </VRow>
<VRow> <VRow>
<VCol cols="12" class="text-center"> <VCol
<VBtn @click="nameTest" :disabled="nameTestLoading"> cols="12"
class="text-center"
>
<VBtn
:disabled="nameTestLoading"
@click="nameTest"
>
<template #prepend> <template #prepend>
<VIcon icon="mdi-text-recognition"></VIcon> <VIcon icon="mdi-text-recognition" />
</template> </template>
{{ nameTestText }} {{ nameTestText }}
</VBtn> </VBtn>
@@ -74,10 +87,13 @@ const nameTest = async () => {
<div v-show="showResult"> <div v-show="showResult">
<VCol> <VCol>
<div <div
class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row"
v-if="nameTestResult?.meta_info?.name" v-if="nameTestResult?.meta_info?.name"
class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row"
>
<div
v-if="nameTestResult?.media_info?.poster_path"
class="ma-auto"
> >
<div class="ma-auto" v-if="nameTestResult?.media_info?.poster_path">
<VImg <VImg
width="10rem" width="10rem"
aspect-ratio="2/3" aspect-ratio="2/3"
@@ -108,28 +124,28 @@ const nameTest = async () => {
<VCardItem> <VCardItem>
<!-- 类型 --> <!-- 类型 -->
<VChip <VChip
v-if="nameTestResult?.media_info?.type || nameTestResult?.meta_info?.type"
variant="elevated" variant="elevated"
class="me-1 mb-1 text-white bg-blue-500" class="me-1 mb-1 text-white bg-blue-500"
v-if="nameTestResult?.media_info?.type || nameTestResult?.meta_info?.type"
> >
{{ {{
nameTestResult?.media_info?.type || nameTestResult?.meta_info?.type nameTestResult?.media_info?.type || nameTestResult?.meta_info?.type
}}</VChip }}
> </VChip>
<!-- 二级分类 --> <!-- 二级分类 -->
<VChip <VChip
v-if="nameTestResult?.media_info?.category"
variant="elevated" variant="elevated"
class="me-1 mb-1 text-white bg-blue-500" class="me-1 mb-1 text-white bg-blue-500"
v-if="nameTestResult?.media_info?.category"
> >
{{ nameTestResult?.media_info?.category }} {{ nameTestResult?.media_info?.category }}
</VChip> </VChip>
<!-- TMDBID --> <!-- TMDBID -->
<VChip <VChip
v-if="nameTestResult?.media_info?.tmdb_id"
variant="elevated" variant="elevated"
color="success" color="success"
class="me-1 mb-1" class="me-1 mb-1"
v-if="nameTestResult?.media_info?.tmdb_id"
> >
{{ nameTestResult?.media_info?.tmdb_id }} {{ nameTestResult?.media_info?.tmdb_id }}
</VChip> </VChip>
@@ -139,8 +155,8 @@ const nameTest = async () => {
variant="elevated" variant="elevated"
class="me-1 mb-1 text-white bg-red-500" class="me-1 mb-1 text-white bg-red-500"
> >
{{ nameTestResult?.meta_info?.edition }}</VChip {{ nameTestResult?.meta_info?.edition }}
> </VChip>
<VChip <VChip
v-if="nameTestResult?.meta_info?.resource_pix" v-if="nameTestResult?.meta_info?.resource_pix"
variant="elevated" variant="elevated"
@@ -172,7 +188,10 @@ const nameTest = async () => {
</VCardItem> </VCardItem>
</div> </div>
</div> </div>
<VAlert icon="mdi-alert-circle-outline" v-if="!nameTestResult?.meta_info?.name"> <VAlert
v-if="!nameTestResult?.meta_info?.name"
icon="mdi-alert-circle-outline"
>
识别失败无法识别到有效信息 识别失败无法识别到有效信息
</VAlert> </VAlert>
</VCol> </VCol>
+106 -92
View File
@@ -1,158 +1,170 @@
<script setup lang="ts"> <script setup lang="ts">
import api from "@/api"; import api from '@/api'
import douban from "@images/logos/douban.png"; import douban from '@images/logos/douban.png'
import github from "@images/logos/github.png"; import github from '@images/logos/github.png'
import slack from "@images/logos/slack.png"; import slack from '@images/logos/slack.png'
import telegram from "@images/logos/telegram.webp"; import telegram from '@images/logos/telegram.webp'
import tmdb from "@images/logos/tmdb.png"; import tmdb from '@images/logos/tmdb.png'
import wechat from "@images/logos/wechat.png"; import wechat from '@images/logos/wechat.png'
interface Status { interface Status {
OK: string; OK: string
Fail: string; Fail: string
Normal: string; Normal: string
Doing?: string; Doing?: string
} }
interface Address { interface Address {
image: string; image: string
name: string; name: string
url: string; url: string
proxy: boolean; proxy: boolean
status: keyof Status; status: keyof Status
time: string; time: string
message: string; message: string
btndisable: boolean; btndisable: boolean
} }
// 测试集 // 测试集
const targets = ref<Address[]>([ const targets = ref<Address[]>([
{ {
image: tmdb, image: tmdb,
name: "api.themoviedb.org", name: 'api.themoviedb.org',
url: "https://api.themoviedb.org/3/movie/550?api_key={TMDBAPIKEY}", url: 'https://api.themoviedb.org/3/movie/550?api_key={TMDBAPIKEY}',
proxy: true, proxy: true,
status: "Normal", status: 'Normal',
time: "", time: '',
message: "", message: '',
btndisable: false, btndisable: false,
}, },
{ {
image: tmdb, image: tmdb,
name: "api.tmdb.org", name: 'api.tmdb.org',
url: "https://api.tmdb.org", url: 'https://api.tmdb.org',
proxy: true, proxy: true,
status: "Normal", status: 'Normal',
time: "", time: '',
message: "未测试", message: '未测试',
btndisable: false, btndisable: false,
}, },
{ {
image: tmdb, image: tmdb,
name: "www.themoviedb.org", name: 'www.themoviedb.org',
url: "https://www.themoviedb.org", url: 'https://www.themoviedb.org',
proxy: true, proxy: true,
status: "Normal", status: 'Normal',
time: "", time: '',
message: "未测试", message: '未测试',
btndisable: false, btndisable: false,
}, },
{ {
image: telegram, image: telegram,
name: "api.telegram.org", name: 'api.telegram.org',
url: "https://api.telegram.org", url: 'https://api.telegram.org',
proxy: true, proxy: true,
status: "Normal", status: 'Normal',
time: "", time: '',
message: "未测试", message: '未测试',
btndisable: false, btndisable: false,
}, },
{ {
image: wechat, image: wechat,
name: "qyapi.weixin.qq.com", name: 'qyapi.weixin.qq.com',
url: "https://qyapi.weixin.qq.com/cgi-bin/gettoken", url: 'https://qyapi.weixin.qq.com/cgi-bin/gettoken',
proxy: false, proxy: false,
status: "Normal", status: 'Normal',
time: "", time: '',
message: "未测试", message: '未测试',
btndisable: false, btndisable: false,
}, },
{ {
image: douban, image: douban,
name: "frodo.douban.com", name: 'frodo.douban.com',
url: "https://frodo.douban.com", url: 'https://frodo.douban.com',
proxy: false, proxy: false,
status: "Normal", status: 'Normal',
time: "", time: '',
message: "未测试", message: '未测试',
btndisable: false, btndisable: false,
}, },
{ {
image: slack, image: slack,
name: "slack.com", name: 'slack.com',
url: "https://slack.com", url: 'https://slack.com',
proxy: false, proxy: false,
status: "Normal", status: 'Normal',
time: "", time: '',
message: "未测试", message: '未测试',
btndisable: false, btndisable: false,
}, },
{ {
image: github, image: github,
name: "github.com", name: 'github.com',
url: "https://github.com", url: 'https://github.com',
proxy: true, proxy: true,
status: "Normal", status: 'Normal',
time: "", time: '',
message: "未测试", message: '未测试',
btndisable: false, btndisable: false,
}, },
]); ])
const resolveStatusColor: Status = { const resolveStatusColor: Status = {
OK: "success", OK: 'success',
Fail: "error", Fail: 'error',
Normal: "", Normal: '',
Doing: "warning", Doing: 'warning',
}; }
// 调用API测试网络连接 // 调用API测试网络连接
const netTest = async (index: number) => { async function netTest(index: number) {
try { try {
const target = targets.value[index]; const target = targets.value[index]
target.btndisable = true;
target.status = "Doing"; target.btndisable = true
target.message = "测试中..."; target.status = 'Doing'
const result: { [key: string]: any } = await api.get("system/nettest", { target.message = '测试中...'
const result: { [key: string]: any } = await api.get('system/nettest', {
params: { params: {
url: target.url, url: target.url,
proxy: target.proxy, proxy: target.proxy,
}, },
}); })
if (result.success) { if (result.success) {
target.status = "OK"; target.status = 'OK'
target.message = "正常"; target.message = '正常'
} else {
target.status = "Fail";
target.message = result.message;
} }
target.time = result.data?.time; else {
target.btndisable = false; target.status = 'Fail'
} catch (error) { target.message = result.message
console.error(error);
} }
}; target.time = result.data?.time
target.btndisable = false
}
catch (error) {
console.error(error)
}
}
// 加载时测试所有连接 // 加载时测试所有连接
onMounted(async () => { onMounted(async () => {
for (let i = 0; i < targets.value.length; i++) { for (let i = 0; i < targets.value.length; i++)
await netTest(i); await netTest(i)
} })
});
</script> </script>
<template> <template>
<VList lines="two" border rounded> <VList
<template v-for="(target, index) of targets" :key="target.name"> lines="two"
border
rounded
>
<template
v-for="(target, index) of targets"
:key="target.name"
>
<VListItem> <VListItem>
<template #prepend> <template #prepend>
<VAvatar :image="target.image" /> <VAvatar :image="target.image" />
@@ -171,7 +183,10 @@ onMounted(async () => {
<span class="ms-4">{{ target.message }}</span> <span class="ms-4">{{ target.message }}</span>
</VBadge> </VBadge>
<span class="text-xs text-wrap text-disabled" v-if="target.time"> <span
v-if="target.time"
class="text-xs text-wrap text-disabled"
>
{{ target.time }} ms {{ target.time }} ms
</span> </span>
</VListItemSubtitle> </VListItemSubtitle>
@@ -179,10 +194,9 @@ onMounted(async () => {
<VBtn <VBtn
size="small" size="small"
icon="mdi-connection" icon="mdi-connection"
@click="netTest(index)"
:disabled="target.btndisable" :disabled="target.btndisable"
> @click="netTest(index)"
</VBtn> />
</template> </template>
</VListItem> </VListItem>
<VDivider v-if="index !== targets.length - 1" /> <VDivider v-if="index !== targets.length - 1" />
+1 -1
View File
@@ -9,7 +9,7 @@ module.exports = {
}, },
plugins: [ plugins: [
require('@tailwindcss/aspect-ratio'), require('@tailwindcss/aspect-ratio'),
// ... // ...
], ],
} }
+1 -2
View File
@@ -1,6 +1,6 @@
import { fileURLToPath } from 'node:url'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx' import vueJsx from '@vitejs/plugin-vue-jsx'
import { fileURLToPath } from 'node:url'
import AutoImport from 'unplugin-auto-import/vite' import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite' import Components from 'unplugin-vue-components/vite'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
@@ -38,7 +38,6 @@ export default defineConfig({
'@images': fileURLToPath(new URL('./src/assets/images/', import.meta.url)), '@images': fileURLToPath(new URL('./src/assets/images/', import.meta.url)),
'@styles': fileURLToPath(new URL('./src/styles/', import.meta.url)), '@styles': fileURLToPath(new URL('./src/styles/', import.meta.url)),
'@configured-variables': fileURLToPath(new URL('./src/styles/variables/_template.scss', import.meta.url)), '@configured-variables': fileURLToPath(new URL('./src/styles/variables/_template.scss', import.meta.url)),
'@axios': fileURLToPath(new URL('./src/plugins/axios', import.meta.url)),
'apexcharts': fileURLToPath(new URL('node_modules/apexcharts-clevision', import.meta.url)), 'apexcharts': fileURLToPath(new URL('node_modules/apexcharts-clevision', import.meta.url)),
}, },
}, },